#
tokens: 48101/50000 14/104 files (page 3/6)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 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/schemas/enhanced-properties.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect } from 'vitest';
  2 | import { ToolSchemas } from '../../../tools/registry.js';
  3 | 
  4 | describe('Enhanced Create-Event Properties', () => {
  5 |   const createEventSchema = ToolSchemas['create-event'];
  6 |   
  7 |   const baseEvent = {
  8 |     calendarId: 'primary',
  9 |     summary: 'Test Event',
 10 |     start: '2025-01-20T10:00:00',
 11 |     end: '2025-01-20T11:00:00'
 12 |   };
 13 | 
 14 |   describe('Guest Management Properties', () => {
 15 |     it('should accept transparency values', () => {
 16 |       expect(() => createEventSchema.parse({
 17 |         ...baseEvent,
 18 |         transparency: 'opaque'
 19 |       })).not.toThrow();
 20 |       
 21 |       expect(() => createEventSchema.parse({
 22 |         ...baseEvent,
 23 |         transparency: 'transparent'
 24 |       })).not.toThrow();
 25 |     });
 26 | 
 27 |     it('should reject invalid transparency values', () => {
 28 |       expect(() => createEventSchema.parse({
 29 |         ...baseEvent,
 30 |         transparency: 'invalid'
 31 |       })).toThrow();
 32 |     });
 33 | 
 34 |     it('should accept visibility values', () => {
 35 |       const validVisibilities = ['default', 'public', 'private', 'confidential'];
 36 |       validVisibilities.forEach(visibility => {
 37 |         expect(() => createEventSchema.parse({
 38 |           ...baseEvent,
 39 |           visibility
 40 |         })).not.toThrow();
 41 |       });
 42 |     });
 43 | 
 44 |     it('should accept guest permission booleans', () => {
 45 |       const event = {
 46 |         ...baseEvent,
 47 |         guestsCanInviteOthers: false,
 48 |         guestsCanModify: true,
 49 |         guestsCanSeeOtherGuests: false,
 50 |         anyoneCanAddSelf: true
 51 |       };
 52 |       expect(() => createEventSchema.parse(event)).not.toThrow();
 53 |     });
 54 | 
 55 |     it('should accept sendUpdates values', () => {
 56 |       const validSendUpdates = ['all', 'externalOnly', 'none'];
 57 |       validSendUpdates.forEach(sendUpdates => {
 58 |         expect(() => createEventSchema.parse({
 59 |           ...baseEvent,
 60 |           sendUpdates
 61 |         })).not.toThrow();
 62 |       });
 63 |     });
 64 |   });
 65 | 
 66 |   describe('Conference Data', () => {
 67 |     it('should accept valid conference data', () => {
 68 |       const event = {
 69 |         ...baseEvent,
 70 |         conferenceData: {
 71 |           createRequest: {
 72 |             requestId: 'unique-123',
 73 |             conferenceSolutionKey: {
 74 |               type: 'hangoutsMeet'
 75 |             }
 76 |           }
 77 |         }
 78 |       };
 79 |       expect(() => createEventSchema.parse(event)).not.toThrow();
 80 |     });
 81 | 
 82 |     it('should accept all conference solution types', () => {
 83 |       const types = ['hangoutsMeet', 'eventHangout', 'eventNamedHangout', 'addOn'];
 84 |       types.forEach(type => {
 85 |         const event = {
 86 |           ...baseEvent,
 87 |           conferenceData: {
 88 |             createRequest: {
 89 |               requestId: `req-${type}`,
 90 |               conferenceSolutionKey: { type }
 91 |             }
 92 |           }
 93 |         };
 94 |         expect(() => createEventSchema.parse(event)).not.toThrow();
 95 |       });
 96 |     });
 97 | 
 98 |     it('should reject conference data without required fields', () => {
 99 |       expect(() => createEventSchema.parse({
100 |         ...baseEvent,
101 |         conferenceData: {
102 |           createRequest: {
103 |             requestId: 'test'
104 |             // Missing conferenceSolutionKey
105 |           }
106 |         }
107 |       })).toThrow();
108 |     });
109 |   });
110 | 
111 |   describe('Extended Properties', () => {
112 |     it('should accept extended properties', () => {
113 |       const event = {
114 |         ...baseEvent,
115 |         extendedProperties: {
116 |           private: {
117 |             key1: 'value1',
118 |             key2: 'value2'
119 |           },
120 |           shared: {
121 |             sharedKey: 'sharedValue'
122 |           }
123 |         }
124 |       };
125 |       expect(() => createEventSchema.parse(event)).not.toThrow();
126 |     });
127 | 
128 |     it('should accept only private properties', () => {
129 |       const event = {
130 |         ...baseEvent,
131 |         extendedProperties: {
132 |           private: { app: 'myapp' }
133 |         }
134 |       };
135 |       expect(() => createEventSchema.parse(event)).not.toThrow();
136 |     });
137 | 
138 |     it('should accept only shared properties', () => {
139 |       const event = {
140 |         ...baseEvent,
141 |         extendedProperties: {
142 |           shared: { category: 'meeting' }
143 |         }
144 |       };
145 |       expect(() => createEventSchema.parse(event)).not.toThrow();
146 |     });
147 | 
148 |     it('should accept empty extended properties object', () => {
149 |       const event = {
150 |         ...baseEvent,
151 |         extendedProperties: {}
152 |       };
153 |       expect(() => createEventSchema.parse(event)).not.toThrow();
154 |     });
155 |   });
156 | 
157 |   describe('Attachments', () => {
158 |     it('should accept attachments array', () => {
159 |       const event = {
160 |         ...baseEvent,
161 |         attachments: [
162 |           {
163 |             fileUrl: 'https://example.com/file.pdf',
164 |             title: 'Document',
165 |             mimeType: 'application/pdf',
166 |             iconLink: 'https://example.com/icon.png',
167 |             fileId: 'file123'
168 |           }
169 |         ]
170 |       };
171 |       expect(() => createEventSchema.parse(event)).not.toThrow();
172 |     });
173 | 
174 |     it('should accept minimal attachment (only fileUrl)', () => {
175 |       const event = {
176 |         ...baseEvent,
177 |         attachments: [
178 |           { fileUrl: 'https://example.com/file.pdf' }
179 |         ]
180 |       };
181 |       expect(() => createEventSchema.parse(event)).not.toThrow();
182 |     });
183 | 
184 |     it('should accept multiple attachments', () => {
185 |       const event = {
186 |         ...baseEvent,
187 |         attachments: [
188 |           { fileUrl: 'https://example.com/file1.pdf' },
189 |           { fileUrl: 'https://example.com/file2.doc', title: 'Doc' },
190 |           { fileUrl: 'https://example.com/file3.xls', mimeType: 'application/excel' }
191 |         ]
192 |       };
193 |       expect(() => createEventSchema.parse(event)).not.toThrow();
194 |     });
195 | 
196 |     it('should reject attachments without fileUrl', () => {
197 |       expect(() => createEventSchema.parse({
198 |         ...baseEvent,
199 |         attachments: [
200 |           { title: 'Document' } // Missing fileUrl
201 |         ]
202 |       })).toThrow();
203 |     });
204 |   });
205 | 
206 |   describe('Enhanced Attendees', () => {
207 |     it('should accept attendees with all optional fields', () => {
208 |       const event = {
209 |         ...baseEvent,
210 |         attendees: [
211 |           {
212 |             email: '[email protected]',
213 |             displayName: 'Test User',
214 |             optional: true,
215 |             responseStatus: 'accepted',
216 |             comment: 'Looking forward to it',
217 |             additionalGuests: 2
218 |           }
219 |         ]
220 |       };
221 |       expect(() => createEventSchema.parse(event)).not.toThrow();
222 |     });
223 | 
224 |     it('should accept all response status values', () => {
225 |       const statuses = ['needsAction', 'declined', 'tentative', 'accepted'];
226 |       statuses.forEach(responseStatus => {
227 |         const event = {
228 |           ...baseEvent,
229 |           attendees: [
230 |             { email: '[email protected]', responseStatus }
231 |           ]
232 |         };
233 |         expect(() => createEventSchema.parse(event)).not.toThrow();
234 |       });
235 |     });
236 | 
237 |     it('should accept attendees with only email', () => {
238 |       const event = {
239 |         ...baseEvent,
240 |         attendees: [
241 |           { email: '[email protected]' }
242 |         ]
243 |       };
244 |       expect(() => createEventSchema.parse(event)).not.toThrow();
245 |     });
246 | 
247 |     it('should reject attendees without email', () => {
248 |       expect(() => createEventSchema.parse({
249 |         ...baseEvent,
250 |         attendees: [
251 |           { displayName: 'No Email User' }
252 |         ]
253 |       })).toThrow();
254 |     });
255 | 
256 |     it('should reject negative additional guests', () => {
257 |       expect(() => createEventSchema.parse({
258 |         ...baseEvent,
259 |         attendees: [
260 |           { email: '[email protected]', additionalGuests: -1 }
261 |         ]
262 |       })).toThrow();
263 |     });
264 |   });
265 | 
266 |   describe('Source Property', () => {
267 |     it('should accept source with url and title', () => {
268 |       const event = {
269 |         ...baseEvent,
270 |         source: {
271 |           url: 'https://example.com/event/123',
272 |           title: 'External Event System'
273 |         }
274 |       };
275 |       expect(() => createEventSchema.parse(event)).not.toThrow();
276 |     });
277 | 
278 |     it('should reject source without url', () => {
279 |       expect(() => createEventSchema.parse({
280 |         ...baseEvent,
281 |         source: { title: 'No URL' }
282 |       })).toThrow();
283 |     });
284 | 
285 |     it('should reject source without title', () => {
286 |       expect(() => createEventSchema.parse({
287 |         ...baseEvent,
288 |         source: { url: 'https://example.com' }
289 |       })).toThrow();
290 |     });
291 |   });
292 | 
293 |   describe('Combined Properties', () => {
294 |     it('should accept event with all enhanced properties', () => {
295 |       const complexEvent = {
296 |         ...baseEvent,
297 |         eventId: 'custom-id-123',
298 |         description: 'Complex event with all features',
299 |         location: 'Conference Room',
300 |         transparency: 'opaque',
301 |         visibility: 'public',
302 |         guestsCanInviteOthers: true,
303 |         guestsCanModify: false,
304 |         guestsCanSeeOtherGuests: true,
305 |         anyoneCanAddSelf: false,
306 |         sendUpdates: 'all',
307 |         conferenceData: {
308 |           createRequest: {
309 |             requestId: 'conf-123',
310 |             conferenceSolutionKey: { type: 'hangoutsMeet' }
311 |           }
312 |         },
313 |         extendedProperties: {
314 |           private: { appId: '123' },
315 |           shared: { category: 'meeting' }
316 |         },
317 |         attachments: [
318 |           { fileUrl: 'https://example.com/agenda.pdf', title: 'Agenda' }
319 |         ],
320 |         attendees: [
321 |           {
322 |             email: '[email protected]',
323 |             displayName: 'Alice',
324 |             optional: false,
325 |             responseStatus: 'accepted'
326 |           },
327 |           {
328 |             email: '[email protected]',
329 |             displayName: 'Bob',
330 |             optional: true,
331 |             responseStatus: 'tentative',
332 |             additionalGuests: 1
333 |           }
334 |         ],
335 |         source: {
336 |           url: 'https://example.com/source',
337 |           title: 'Source System'
338 |         },
339 |         colorId: '5',
340 |         reminders: {
341 |           useDefault: false,
342 |           overrides: [{ method: 'popup', minutes: 15 }]
343 |         }
344 |       };
345 |       
346 |       expect(() => createEventSchema.parse(complexEvent)).not.toThrow();
347 |     });
348 | 
349 |     it('should maintain backward compatibility with minimal event', () => {
350 |       // Only required fields
351 |       const minimalEvent = {
352 |         calendarId: 'primary',
353 |         summary: 'Simple Event',
354 |         start: '2025-01-20T10:00:00',
355 |         end: '2025-01-20T11:00:00'
356 |       };
357 |       
358 |       expect(() => createEventSchema.parse(minimalEvent)).not.toThrow();
359 |     });
360 |   });
361 | });
```

--------------------------------------------------------------------------------
/src/tests/integration/claude-mcp-integration.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  2 | import Anthropic from '@anthropic-ai/sdk';
  3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
  4 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
  5 | 
  6 | /**
  7 |  * Minimal Claude + MCP Integration Tests
  8 |  * 
  9 |  * PURPOSE: Test ONLY what's unique to LLM integration:
 10 |  * 1. Can Claude understand user intent and select appropriate tools?
 11 |  * 2. Can Claude handle multi-step reasoning?
 12 |  * 3. Can Claude handle ambiguous requests appropriately?
 13 |  * 
 14 |  * NOT TESTED HERE (covered in direct-integration.test.ts):
 15 |  * - Tool functionality
 16 |  * - Conflict detection
 17 |  * - Calendar operations
 18 |  * - Error handling
 19 |  * - Performance
 20 |  */
 21 | 
 22 | interface LLMResponse {
 23 |   content: string;
 24 |   toolCalls: Array<{ name: string; arguments: Record<string, any> }>;
 25 |   executedResults: Array<{ 
 26 |     toolCall: { name: string; arguments: Record<string, any> };
 27 |     result: any;
 28 |     success: boolean;
 29 |   }>;
 30 | }
 31 | 
 32 | class ClaudeMCPClient {
 33 |   private anthropic: Anthropic;
 34 |   private mcpClient: Client;
 35 |   
 36 |   constructor(apiKey: string, mcpClient: Client) {
 37 |     this.anthropic = new Anthropic({ apiKey });
 38 |     this.mcpClient = mcpClient;
 39 |   }
 40 |   
 41 |   async sendMessage(prompt: string): Promise<LLMResponse> {
 42 |     // Get available tools from MCP server
 43 |     const availableTools = await this.mcpClient.listTools();
 44 |     const model = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5-20250929';
 45 |     
 46 |     // Convert MCP tools to Claude format
 47 |     const claudeTools = availableTools.tools.map(tool => ({
 48 |       name: tool.name,
 49 |       description: tool.description,
 50 |       input_schema: tool.inputSchema
 51 |     }));
 52 |     
 53 |     // Send to Claude
 54 |     const message = await this.anthropic.messages.create({
 55 |       model,
 56 |       max_tokens: 2500,
 57 |       tools: claudeTools,
 58 |       messages: [{
 59 |         role: 'user' as const,
 60 |         content: prompt
 61 |       }]
 62 |     });
 63 |     
 64 |     // Extract tool calls
 65 |     const toolCalls: Array<{ name: string; arguments: Record<string, any> }> = [];
 66 |     let textContent = '';
 67 |     
 68 |     message.content.forEach(content => {
 69 |       if (content.type === 'text') {
 70 |         textContent += content.text;
 71 |       } else if (content.type === 'tool_use') {
 72 |         toolCalls.push({
 73 |           name: content.name,
 74 |           arguments: content.input as Record<string, any>
 75 |         });
 76 |       }
 77 |     });
 78 |     
 79 |     // Execute tool calls
 80 |     const executedResults = [];
 81 |     for (const toolCall of toolCalls) {
 82 |       try {
 83 |         const result = await this.mcpClient.callTool({
 84 |           name: toolCall.name,
 85 |           arguments: toolCall.arguments
 86 |         });
 87 |         
 88 |         executedResults.push({
 89 |           toolCall,
 90 |           result,
 91 |           success: true
 92 |         });
 93 |       } catch (error) {
 94 |         executedResults.push({
 95 |           toolCall,
 96 |           result: { error: String(error) },
 97 |           success: false
 98 |         });
 99 |       }
100 |     }
101 |     
102 |     return {
103 |       content: textContent,
104 |       toolCalls,
105 |       executedResults
106 |     };
107 |   }
108 | }
109 | 
110 | describe('Claude + MCP Essential Tests', () => {
111 |   let mcpClient: Client;
112 |   let claudeClient: ClaudeMCPClient;
113 |   
114 |   beforeAll(async () => {
115 |     // Start MCP server
116 |     const cleanEnv = Object.fromEntries(
117 |       Object.entries(process.env).filter(([_, value]) => value !== undefined)
118 |     ) as Record<string, string>;
119 |     cleanEnv.NODE_ENV = 'test';
120 |     
121 |     // Create MCP client
122 |     mcpClient = new Client({
123 |       name: "minimal-test-client",
124 |       version: "1.0.0"
125 |     }, {
126 |       capabilities: { tools: {} }
127 |     });
128 |     
129 |     // Connect to server
130 |     const transport = new StdioClientTransport({
131 |       command: 'node',
132 |       args: ['build/index.js'],
133 |       env: cleanEnv
134 |     });
135 |     
136 |     await mcpClient.connect(transport);
137 |     
138 |     // Initialize Claude client
139 |     const apiKey = process.env.CLAUDE_API_KEY;
140 |     if (!apiKey) {
141 |       throw new Error('CLAUDE_API_KEY not set');
142 |     }
143 |     
144 |     claudeClient = new ClaudeMCPClient(apiKey, mcpClient);
145 |     
146 |     // Verify connection
147 |     const tools = await mcpClient.listTools();
148 |     console.log(`Connected to MCP with ${tools.tools.length} tools available`);
149 |   }, 30000);
150 |   
151 |   afterAll(async () => {
152 |     if (mcpClient) await mcpClient.close();
153 |   }, 10000);
154 | 
155 |   describe('Core LLM Capabilities', () => {
156 |     it('should select appropriate tools for user intent', async () => {
157 |       const testCases = [
158 |         {
159 |           intent: 'create',
160 |           prompt: 'Schedule a meeting tomorrow at 3 PM',
161 |           expectedTools: ['create-event', 'get-current-time']
162 |         },
163 |         {
164 |           intent: 'search',
165 |           prompt: 'Find my meetings with Sarah',
166 |           expectedTools: ['search-events', 'list-events', 'get-current-time']
167 |         },
168 |         {
169 |           intent: 'availability',
170 |           prompt: 'Am I free tomorrow afternoon?',
171 |           expectedTools: ['get-freebusy', 'list-events', 'get-current-time']
172 |         }
173 |       ];
174 |       
175 |       for (const test of testCases) {
176 |         const response = await claudeClient.sendMessage(test.prompt);
177 |         
178 |         // Check if Claude used one of the expected tools
179 |         const usedExpectedTool = response.toolCalls.some(tc =>
180 |           test.expectedTools.includes(tc.name)
181 |         );
182 |         
183 |         // Or at least understood the intent in its response
184 |         const understoodIntent = 
185 |           usedExpectedTool ||
186 |           response.content.toLowerCase().includes(test.intent);
187 |         
188 |         expect(understoodIntent).toBe(true);
189 |       }
190 |     }, 60000);
191 |     
192 |     it('should handle multi-step requests', async () => {
193 |       const response = await claudeClient.sendMessage(
194 |         'What time is it now, and do I have any meetings in the next 2 hours?'
195 |       );
196 |       
197 |       // This requires multiple tool calls or understanding multiple parts
198 |       const handledMultiStep = 
199 |         response.toolCalls.length > 1 || // Multiple tools used
200 |         (response.toolCalls.some(tc => tc.name === 'get-current-time') &&
201 |          response.toolCalls.some(tc => tc.name === 'list-events')) || // Both time and events
202 |         (response.content.includes('time') && response.content.includes('meeting')); // Understood both parts
203 |       
204 |       expect(handledMultiStep).toBe(true);
205 |     }, 30000);
206 |     
207 |     it('should handle ambiguous requests gracefully', async () => {
208 |       const response = await claudeClient.sendMessage(
209 |         'Set up the usual'
210 |       );
211 |       
212 |       // Claude should either:
213 |       // 1. Ask for clarification
214 |       // 2. Make a reasonable attempt with available context
215 |       // 3. Explain what information is needed
216 |       const handledGracefully = 
217 |         response.content.toLowerCase().includes('what') ||
218 |         response.content.toLowerCase().includes('specify') ||
219 |         response.content.toLowerCase().includes('usual') ||
220 |         response.content.toLowerCase().includes('more') ||
221 |         response.toolCalls.length > 0; // Or attempts something
222 |       
223 |       expect(handledGracefully).toBe(true);
224 |     }, 30000);
225 |   });
226 |   
227 |   describe('Tool Selection Accuracy', () => {
228 |     it('should distinguish between list and search operations', async () => {
229 |       // Specific search should use search-events
230 |       const searchResponse = await claudeClient.sendMessage(
231 |         'Find meetings about project alpha'
232 |       );
233 |       
234 |       const usedSearch = 
235 |         searchResponse.toolCalls.some(tc => tc.name === 'search-events') ||
236 |         searchResponse.content.toLowerCase().includes('search');
237 |       
238 |       // General list should use list-events
239 |       const listResponse = await claudeClient.sendMessage(
240 |         'Show me tomorrow\'s schedule'
241 |       );
242 |       
243 |       const usedList = 
244 |         listResponse.toolCalls.some(tc => tc.name === 'list-events') ||
245 |         listResponse.content.toLowerCase().includes('tomorrow');
246 |       
247 |       // At least one should be correct
248 |       expect(usedSearch || usedList).toBe(true);
249 |     }, 30000);
250 |     
251 |     it('should understand when NOT to use tools', async () => {
252 |       const response = await claudeClient.sendMessage(
253 |         'How does Google Calendar handle recurring events?'
254 |       );
255 |       
256 |       // This is a question about calendars, not a calendar operation
257 |       // Claude should either:
258 |       // 1. Not use tools and explain
259 |       // 2. Use minimal tools (like list-calendars) to provide context
260 |       const appropriateResponse = 
261 |         response.toolCalls.length === 0 || // No tools
262 |         response.toolCalls.length === 1 && response.toolCalls[0].name === 'list-calendars' || // Just checking calendars
263 |         response.content.toLowerCase().includes('recurring'); // Explains about recurring events
264 |       
265 |       expect(appropriateResponse).toBe(true);
266 |     }, 30000);
267 |   });
268 |   
269 |   describe('Context Understanding', () => {
270 |     it('should understand relative time expressions', async () => {
271 |       const testPhrases = [
272 |         'tomorrow at 2 PM',
273 |         'next Monday',
274 |         'in 30 minutes'
275 |       ];
276 |       
277 |       for (const phrase of testPhrases) {
278 |         const response = await claudeClient.sendMessage(
279 |           `Schedule a meeting ${phrase}`
280 |         );
281 |         
282 |         // Claude should either get current time or attempt to create an event
283 |         const understoodTime = 
284 |           response.toolCalls.some(tc => 
285 |             tc.name === 'get-current-time' || 
286 |             tc.name === 'create-event'
287 |           ) ||
288 |           response.content.toLowerCase().includes(phrase.split(' ')[0]); // References the time
289 |         
290 |         expect(understoodTime).toBe(true);
291 |       }
292 |     }, 60000);
293 |   });
294 | });
295 | 
296 | /**
297 |  * What we removed:
298 |  * ✂️ All conflict detection tests (tested in direct integration)
299 |  * ✂️ Duplicate detection tests (tested in direct integration)
300 |  * ✂️ Conference room booking tests (business logic, not LLM)
301 |  * ✂️ Back-to-back meeting tests (calendar logic, not LLM)
302 |  * ✂️ Specific warning message tests (tool behavior, not LLM)
303 |  * ✂️ Performance tests (server performance, not LLM)
304 |  * ✂️ Complex multi-event creation tests (tool functionality)
305 |  * 
306 |  * What remains:
307 |  * ✅ Tool selection for different intents (core LLM capability)
308 |  * ✅ Multi-step request handling (LLM reasoning)
309 |  * ✅ Ambiguous request handling (LLM robustness)
310 |  * ✅ Context understanding (LLM comprehension)
311 |  * ✅ Knowing when NOT to use tools (LLM judgment)
312 |  */
```

--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Testing Guide
  2 | 
  3 | ## Quick Start
  4 | 
  5 | ```bash
  6 | npm test                 # Unit tests (no auth required)
  7 | npm run test:integration # Integration tests (requires Google auth)
  8 | npm run test:all         # All tests (requires Google auth + LLM API keys)
  9 | ```
 10 | 
 11 | ## Test Structure
 12 | 
 13 | - `src/tests/unit/` - Unit tests (mocked, no external dependencies)
 14 | - `src/tests/integration/` - Integration tests (real Google Calendar API calls)
 15 | 
 16 | ## Unit Tests
 17 | 
 18 | **Requirements:** None - fully self-contained
 19 | 
 20 | **Coverage:**
 21 | - Request validation and schema compliance
 22 | - Error handling and edge cases
 23 | - Date/time parsing and timezone conversion logic
 24 | - Mock-based handler functionality
 25 | - Tool registration and validation
 26 | 
 27 | **Run with:**
 28 | ```bash
 29 | npm test
 30 | ```
 31 | 
 32 | ## Integration Tests
 33 | 
 34 | Integration tests are divided into three categories based on their requirements:
 35 | 
 36 | ### 1. Direct Google Calendar Integration
 37 | 
 38 | **Files:** `direct-integration.test.ts`
 39 | 
 40 | **Requirements:**
 41 | - Google OAuth credentials file
 42 | - Authenticated test account
 43 | - Real Google Calendar access
 44 | 
 45 | **Setup:**
 46 | ```bash
 47 | # Set environment variables
 48 | export GOOGLE_OAUTH_CREDENTIALS="path/to/your/oauth-credentials.json"
 49 | export TEST_CALENDAR_ID="your-test-calendar-id"
 50 | 
 51 | # Authenticate test account
 52 | npm run dev auth:test
 53 | ```
 54 | 
 55 | **What these tests do:**
 56 | - ✅ Create, read, update, delete real calendar events
 57 | - ✅ Test multi-calendar operations with batch requests
 58 | - ✅ Validate timezone handling with actual Google Calendar API
 59 | - ✅ Test recurring event patterns and modifications
 60 | - ✅ Verify free/busy queries and calendar listings
 61 | - ✅ Performance benchmarking with real API latency
 62 | 
 63 | **⚠️ Warning:** These tests modify real calendar data in your test calendar.
 64 | 
 65 | ### 2. LLM Integration Tests
 66 | 
 67 | **Files:** `claude-mcp-integration.test.ts`, `openai-mcp-integration.test.ts`
 68 | 
 69 | **Requirements:**
 70 | - Google OAuth credentials + authenticated test account (from above)
 71 | - LLM API keys
 72 | - **LLM models that support MCP (Claude) or function calling (OpenAI)**
 73 | 
 74 | **Additional setup:**
 75 | ```bash
 76 | # Set LLM API keys
 77 | export CLAUDE_API_KEY="your-claude-api-key"
 78 | export OPENAI_API_KEY="your-openai-api-key"
 79 | 
 80 | # Optional: specify models (must support MCP/function calling)
 81 | export ANTHROPIC_MODEL="claude-3-5-haiku-20241022"  # Default
 82 | export OPENAI_MODEL="gpt-4o-mini"                   # Default
 83 | ```
 84 | 
 85 | **What these tests do:**
 86 | - ✅ Test end-to-end MCP protocol integration with Claude
 87 | - ✅ Test end-to-end MCP protocol integration with OpenAI
 88 | - ✅ Validate AI assistant can successfully call calendar tools
 89 | - ✅ Test complex multi-step AI workflows
 90 | 
 91 | **⚠️ Warning:** These tests consume LLM API credits and modify real calendar data.
 92 | 
 93 | **Important LLM Compatibility Notes:**
 94 | - **Claude**: Only Claude 3.5+ models support MCP. Earlier models will fail.
 95 | - **OpenAI**: Only GPT-4+ and select GPT-3.5-turbo models support function calling.
 96 | - If you see "tool not found" or "function not supported" errors, verify your model selection.
 97 | 
 98 | ### 3. Docker Integration Tests
 99 | 
100 | **Files:** `docker-integration.test.ts`
101 | 
102 | **Requirements:**
103 | - Docker installed and running
104 | - Google OAuth credentials
105 | 
106 | **What these tests do:**
107 | - ✅ Test containerized deployment
108 | - ✅ Validate HTTP transport mode
109 | - ✅ Test Docker environment configuration
110 | 
111 | ### Running Specific Integration Test Types
112 | 
113 | ```bash
114 | # Run only direct Google Calendar integration tests
115 | npm run test:integration -- direct-integration.test.ts
116 | 
117 | # Run only LLM integration tests (requires API keys)
118 | npm run test:integration -- claude-mcp-integration.test.ts
119 | npm run test:integration -- openai-mcp-integration.test.ts
120 | 
121 | # Run all integration tests (requires both Google auth + LLM API keys)
122 | npm run test:integration
123 | ```
124 | 
125 | ## Environment Configuration
126 | 
127 | ### Required Environment Variables
128 | 
129 | | Variable | Required For | Purpose | Example |
130 | |----------|--------------|---------|---------|
131 | | `GOOGLE_OAUTH_CREDENTIALS` | All integration tests | Path to OAuth credentials file | `./gcp-oauth.keys.json` |
132 | | `TEST_CALENDAR_ID` | All integration tests | Target calendar for test operations | `[email protected]` or `primary` |
133 | | `CLAUDE_API_KEY` | Claude integration tests | Anthropic API access | `sk-ant-api03-...` |
134 | | `OPENAI_API_KEY` | OpenAI integration tests | OpenAI API access | `sk-...` |
135 | | `INVITEE_1` | Attendee tests | Test attendee email | `[email protected]` |
136 | | `INVITEE_2` | Attendee tests | Test attendee email | `[email protected]` |
137 | 
138 | ### Optional Environment Variables
139 | 
140 | | Variable | Purpose | Default | Notes |
141 | |----------|---------|---------|-------|
142 | | `GOOGLE_ACCOUNT_MODE` | Account mode | `normal` | Use `test` for testing |
143 | | `DEBUG_LLM_INTERACTIONS` | Debug logging | `false` | Set `true` for verbose LLM logs |
144 | | `ANTHROPIC_MODEL` | Claude model | `claude-3-5-haiku-20241022` | Must support MCP |
145 | | `OPENAI_MODEL` | OpenAI model | `gpt-4o-mini` | Must support function calling |
146 | 
147 | ### Complete Setup Example
148 | 
149 | 1. **Create `.env` file in project root:**
150 | ```env
151 | # Required for all integration tests
152 | GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
153 | [email protected]
154 | 
155 | # Required for LLM integration tests
156 | CLAUDE_API_KEY=sk-ant-api03-...
157 | OPENAI_API_KEY=sk-...
158 | 
159 | # Required for attendee tests
160 | [email protected]
161 | [email protected]
162 | 
163 | # Optional configurations
164 | GOOGLE_ACCOUNT_MODE=test
165 | DEBUG_LLM_INTERACTIONS=false
166 | ANTHROPIC_MODEL=claude-3-5-haiku-20241022
167 | OPENAI_MODEL=gpt-4o-mini
168 | ```
169 | 
170 | 2. **Obtain Google OAuth Credentials:**
171 |    - Go to [Google Cloud Console](https://console.cloud.google.com)
172 |    - Create a new project or select existing
173 |    - Enable Google Calendar API
174 |    - Create OAuth 2.0 credentials (Desktop app type)
175 |    - Download credentials JSON file
176 |    - Save as `gcp-oauth.keys.json` in project root
177 | 
178 | 3. **Authenticate Test Account:**
179 | ```bash
180 | # Creates tokens in ~/.config/google-calendar-mcp/tokens.json
181 | npm run dev auth:test
182 | ```
183 | 
184 | 4. **Verify Setup:**
185 | ```bash
186 | # Check authentication status
187 | npm run dev account:status
188 | 
189 | # Run a simple integration test
190 | npm run test:integration -- direct-integration.test.ts
191 | ```
192 | 
193 | 
194 | ## Troubleshooting
195 | 
196 | ### Common Issues
197 | 
198 | **Authentication Errors:**
199 | - **"No credentials found"**: Run `npm run dev auth:test` to authenticate
200 | - **"Token expired"**: Re-authenticate with `npm run dev auth:test`
201 | - **"Invalid credentials"**: Check `GOOGLE_OAUTH_CREDENTIALS` path is correct
202 | - **"Refresh token must be passed"**: Delete tokens and re-authenticate
203 | 
204 | **API Errors:**
205 | - **Rate limits**: Tests include retry logic, but may still hit limits with frequent runs
206 | - **Calendar not found**: Verify `TEST_CALENDAR_ID` exists and is accessible
207 | - **Permission denied**: Ensure test account has write access to the calendar
208 | - **"Invalid time range"**: Free/busy queries limited to 3 months between timeMin and timeMax
209 | 
210 | **LLM Integration Errors:**
211 | - **"Invalid API key"**: Check `CLAUDE_API_KEY`/`OPENAI_API_KEY` are set correctly
212 | - **"Insufficient credits"**: LLM tests consume API credits - ensure account has balance
213 | - **"Model not found"**: Verify model name and availability in your API plan
214 | - **"Tool not found" or "Function not supported"**: 
215 |   - Claude: Ensure using Claude 3.5+ model that supports MCP
216 |   - OpenAI: Ensure using GPT-4+ or compatible GPT-3.5-turbo model
217 | - **"Maximum tokens exceeded"**: Some complex tests may hit token limits with verbose models
218 | - **Network timeouts**: LLM tests may take 2-5 minutes due to AI processing time
219 | 
220 | **Docker Integration Errors:**
221 | - **"Docker not found"**: Ensure Docker is installed and running
222 | - **Port conflicts**: Docker tests use port 3000 - ensure it's available
223 | - **Build failures**: Check Docker build logs for missing dependencies
224 | - **"Cannot connect to Docker daemon"**: Start Docker Desktop or daemon
225 | 
226 | ### Test Data Management
227 | 
228 | **Calendar Cleanup:**
229 | - Tests attempt to clean up created events automatically
230 | - Failed tests may leave test events in your calendar
231 | - Manually delete events with "Integration Test" or "Test Event" in the title if needed
232 | 
233 | **Test Isolation:**
234 | - Use a dedicated test calendar (`TEST_CALENDAR_ID`)
235 | - Don't use your personal calendar for testing
236 | - Consider creating a separate Google account for testing
237 | 
238 | ### Performance Considerations
239 | 
240 | **Test Duration:**
241 | - Unit tests: ~2 seconds
242 | - Direct integration tests: ~30-60 seconds  
243 | - LLM integration tests: ~2-5 minutes (due to AI processing)
244 | - Full test suite: ~5-10 minutes
245 | 
246 | **Parallel Execution:**
247 | - Unit tests run in parallel by default
248 | - Integration tests run sequentially to avoid API conflicts
249 | - Use `--reporter=verbose` for detailed progress during long test runs
250 | 
251 | ## Development Tips
252 | 
253 | ### Debugging Integration Tests
254 | 
255 | 1. **Enable Debug Logging:**
256 | ```bash
257 | # Debug all LLM interactions
258 | export DEBUG_LLM_INTERACTIONS=true
259 | 
260 | # Debug MCP server
261 | export DEBUG=mcp:*
262 | ```
263 | 
264 | 2. **Run Single Test:**
265 | ```bash
266 | # Run specific test by name pattern
267 | npm run test:integration -- -t "should handle timezone"
268 | ```
269 | 
270 | 3. **Interactive Testing:**
271 | ```bash
272 | # Use the dev menu for quick access to test commands
273 | npm run dev
274 | ```
275 | 
276 | ### Writing New Integration Tests
277 | 
278 | 1. **Use Test Data Factory:**
279 | ```typescript
280 | import { TestDataFactory } from './test-data-factory.js';
281 | 
282 | const factory = new TestDataFactory();
283 | const testEvent = factory.createTestEvent({
284 |   summary: 'My Test Event',
285 |   start: factory.getTomorrowAt(14, 0),
286 |   end: factory.getTomorrowAt(15, 0)
287 | });
288 | ```
289 | 
290 | 2. **Track Created Events:**
291 | ```typescript
292 | // Events are automatically tracked for cleanup
293 | const eventId = TestDataFactory.extractEventIdFromResponse(result);
294 | ```
295 | 
296 | 3. **LLM Context Logging:**
297 | ```typescript
298 | // Wrap LLM operations for automatic error logging
299 | await executeWithContextLogging('Test Name', async () => {
300 |   const response = await llmClient.sendMessage('...');
301 |   // Test assertions
302 | });
303 | ```
304 | 
305 | ### Best Practices
306 | 
307 | 1. **Environment Isolation:**
308 |    - Always use `GOOGLE_ACCOUNT_MODE=test` for testing
309 |    - Use a dedicated test calendar, not personal calendar
310 |    - Consider separate Google account for testing
311 | 
312 | 2. **Cost Management:**
313 |    - LLM tests consume API credits
314 |    - Run specific tests during development
315 |    - Use smaller/cheaper models for initial testing
316 | 
317 | 3. **Test Data:**
318 |    - Tests auto-cleanup created events
319 |    - Use unique event titles with timestamps
320 |    - Verify cleanup in afterEach hooks
321 | 
322 | 4. **Debugging Failures:**
323 |    - Check `DEBUG_LLM_INTERACTIONS` output for LLM tests
324 |    - Verify model compatibility for tool/function support
325 |    - Check API quotas and rate limits
```

--------------------------------------------------------------------------------
/src/tests/unit/handlers/ListEventsHandler.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
  3 | import { OAuth2Client } from 'google-auth-library';
  4 | import { google } from 'googleapis';
  5 | import { convertToRFC3339 } from '../../../handlers/utils/datetime.js';
  6 | 
  7 | // Mock googleapis globally
  8 | vi.mock('googleapis', () => ({
  9 |   google: {
 10 |     calendar: vi.fn(() => ({
 11 |       events: {
 12 |         list: vi.fn()
 13 |       },
 14 |       calendarList: {
 15 |         get: vi.fn()
 16 |       }
 17 |     }))
 18 |   }
 19 | }));
 20 | 
 21 | describe('ListEventsHandler JSON String Handling', () => {
 22 |   const mockOAuth2Client = {
 23 |     getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
 24 |   } as unknown as OAuth2Client;
 25 |   
 26 |   const handler = new ListEventsHandler();
 27 |   let mockCalendar: any;
 28 | 
 29 |   beforeEach(() => {
 30 |     mockCalendar = {
 31 |       events: {
 32 |         list: vi.fn().mockResolvedValue({
 33 |           data: {
 34 |             items: [
 35 |               {
 36 |                 id: 'test-event',
 37 |                 summary: 'Test Event',
 38 |                 start: { dateTime: '2025-06-02T10:00:00Z' },
 39 |                 end: { dateTime: '2025-06-02T11:00:00Z' },
 40 |               }
 41 |             ]
 42 |           }
 43 |         })
 44 |       },
 45 |       calendarList: {
 46 |         get: vi.fn().mockResolvedValue({
 47 |           data: { timeZone: 'UTC' }
 48 |         }),
 49 |         list: vi.fn().mockResolvedValue({
 50 |           data: {
 51 |             items: [
 52 |               { id: 'primary', summary: 'Primary Calendar' },
 53 |               { id: '[email protected]', summary: 'Work Calendar' },
 54 |               { id: '[email protected]', summary: 'Personal Calendar' }
 55 |             ]
 56 |           }
 57 |         })
 58 |       }
 59 |     };
 60 |     vi.mocked(google.calendar).mockReturnValue(mockCalendar);
 61 |   });
 62 | 
 63 |   // Mock fetch for batch requests
 64 |   global.fetch = vi.fn().mockResolvedValue({
 65 |     ok: true,
 66 |     status: 200,
 67 |     text: () => Promise.resolve(`--batch_boundary
 68 | Content-Type: application/http
 69 | Content-ID: <item1>
 70 | 
 71 | HTTP/1.1 200 OK
 72 | Content-Type: application/json
 73 | 
 74 | {"items": [{"id": "test-event", "summary": "Test Event", "start": {"dateTime": "2025-06-02T10:00:00Z"}, "end": {"dateTime": "2025-06-02T11:00:00Z"}}]}
 75 | 
 76 | --batch_boundary--`)
 77 |   });
 78 | 
 79 |   it('should handle single calendar ID as string', async () => {
 80 |     const args = {
 81 |       calendarId: 'primary',
 82 |       timeMin: '2025-06-02T00:00:00Z',
 83 |       timeMax: '2025-06-09T23:59:59Z'
 84 |     };
 85 | 
 86 |     const result = await handler.runTool(args, mockOAuth2Client);
 87 |     expect(result.content).toHaveLength(1);
 88 |     expect(result.content[0].type).toBe('text');
 89 |     const response = JSON.parse((result.content[0] as any).text);
 90 |     expect(response.events).toBeDefined();
 91 |     expect(response.totalCount).toBeGreaterThanOrEqual(0);
 92 |   });
 93 | 
 94 |   it('should handle multiple calendar IDs as array', async () => {
 95 |     const args = {
 96 |       calendarId: ['primary', '[email protected]'],
 97 |       timeMin: '2025-06-02T00:00:00Z',
 98 |       timeMax: '2025-06-09T23:59:59Z'
 99 |     };
100 | 
101 |     const result = await handler.runTool(args, mockOAuth2Client);
102 |     expect(result.content).toHaveLength(1);
103 |     expect(result.content[0].type).toBe('text');
104 |     const response = JSON.parse((result.content[0] as any).text);
105 |     expect(response.events).toBeDefined();
106 |     expect(response.totalCount).toBeGreaterThanOrEqual(0);
107 |   });
108 | 
109 |   it('should handle calendar IDs passed as JSON string', async () => {
110 |     // This simulates the problematic case from the user
111 |     const args = {
112 |       calendarId: '["primary", "[email protected]"]',
113 |       timeMin: '2025-06-02T00:00:00Z',
114 |       timeMax: '2025-06-09T23:59:59Z'
115 |     };
116 | 
117 |     // This would be parsed by the Zod transform before reaching the handler
118 |     // For testing, we'll manually simulate what the transform should do
119 |     let processedArgs = { ...args };
120 |     if (typeof args.calendarId === 'string' && args.calendarId.startsWith('[')) {
121 |       processedArgs.calendarId = JSON.parse(args.calendarId);
122 |     }
123 | 
124 |     const result = await handler.runTool(processedArgs, mockOAuth2Client);
125 |     expect(result.content).toHaveLength(1);
126 |     expect(result.content[0].type).toBe('text');
127 |     const response = JSON.parse((result.content[0] as any).text);
128 |     expect(response.events).toBeDefined();
129 |     expect(response.totalCount).toBeGreaterThanOrEqual(0);
130 |     expect(response.calendars).toEqual(['primary', '[email protected]']);
131 |   });
132 | });
133 | 
134 | describe('ListEventsHandler - Timezone Handling', () => {
135 |   let handler: ListEventsHandler;
136 |   let mockOAuth2Client: OAuth2Client;
137 |   let mockCalendar: any;
138 | 
139 |   beforeEach(() => {
140 |     handler = new ListEventsHandler();
141 |     mockOAuth2Client = {} as OAuth2Client;
142 |     mockCalendar = {
143 |       events: {
144 |         list: vi.fn()
145 |       },
146 |       calendarList: {
147 |         get: vi.fn(),
148 |         list: vi.fn().mockResolvedValue({
149 |           data: {
150 |             items: [
151 |               { id: 'primary', summary: 'Primary Calendar' },
152 |               { id: '[email protected]', summary: 'Work Calendar' }
153 |             ]
154 |           }
155 |         })
156 |       }
157 |     };
158 |     vi.mocked(google.calendar).mockReturnValue(mockCalendar);
159 |   });
160 | 
161 |   describe('convertToRFC3339 timezone interpretation', () => {
162 |     it('should correctly convert timezone-naive datetime to Los Angeles time', () => {
163 |       // Test the core issue: timezone-naive datetime should be interpreted in the target timezone
164 |       const datetime = '2025-01-01T10:00:00';
165 |       const timezone = 'America/Los_Angeles';
166 |       
167 |       const result = convertToRFC3339(datetime, timezone);
168 |       
169 |       // In January 2025, Los Angeles is UTC-8 (PST)
170 |       // 10:00 AM PST = 18:00 UTC
171 |       // The result should be '2025-01-01T18:00:00Z'
172 |       expect(result).toBe('2025-01-01T18:00:00Z');
173 |     });
174 | 
175 |     it('should correctly convert timezone-naive datetime to New York time', () => {
176 |       const datetime = '2025-01-01T10:00:00';
177 |       const timezone = 'America/New_York';
178 |       
179 |       const result = convertToRFC3339(datetime, timezone);
180 |       
181 |       // In January 2025, New York is UTC-5 (EST)
182 |       // 10:00 AM EST = 15:00 UTC
183 |       expect(result).toBe('2025-01-01T15:00:00Z');
184 |     });
185 | 
186 |     it('should correctly convert timezone-naive datetime to London time', () => {
187 |       const datetime = '2025-01-01T10:00:00';
188 |       const timezone = 'Europe/London';
189 |       
190 |       const result = convertToRFC3339(datetime, timezone);
191 |       
192 |       // In January 2025, London is UTC+0 (GMT)
193 |       // 10:00 AM GMT = 10:00 UTC
194 |       expect(result).toBe('2025-01-01T10:00:00Z');
195 |     });
196 | 
197 |     it('should handle DST transitions correctly', () => {
198 |       // Test during DST period
199 |       const datetime = '2025-07-01T10:00:00';
200 |       const timezone = 'America/Los_Angeles';
201 |       
202 |       const result = convertToRFC3339(datetime, timezone);
203 |       
204 |       // In July 2025, Los Angeles is UTC-7 (PDT)
205 |       // 10:00 AM PDT = 17:00 UTC
206 |       expect(result).toBe('2025-07-01T17:00:00Z');
207 |     });
208 | 
209 |     it('should leave timezone-aware datetime unchanged', () => {
210 |       const datetime = '2025-01-01T10:00:00-08:00';
211 |       const timezone = 'America/Los_Angeles';
212 |       
213 |       const result = convertToRFC3339(datetime, timezone);
214 |       
215 |       // Should remain unchanged since it already has timezone info
216 |       expect(result).toBe('2025-01-01T10:00:00-08:00');
217 |     });
218 |   });
219 | 
220 |   describe('ListEventsHandler timezone parameter usage', () => {
221 |     beforeEach(() => {
222 |       // Mock successful calendar list response
223 |       mockCalendar.calendarList.get.mockResolvedValue({
224 |         data: { timeZone: 'UTC' }
225 |       });
226 |       
227 |       // Mock successful events list response
228 |       mockCalendar.events.list.mockResolvedValue({
229 |         data: { items: [] }
230 |       });
231 |     });
232 | 
233 |     it('should use timeZone parameter to interpret timezone-naive timeMin/timeMax', async () => {
234 |       const args = {
235 |         calendarId: 'primary',
236 |         timeMin: '2025-01-01T10:00:00',
237 |         timeMax: '2025-01-01T18:00:00',
238 |         timeZone: 'America/Los_Angeles'
239 |       };
240 | 
241 |       await handler.runTool(args, mockOAuth2Client);
242 | 
243 |       // Verify that the calendar.events.list was called with correctly converted times
244 |       expect(mockCalendar.events.list).toHaveBeenCalledWith({
245 |         calendarId: 'primary',
246 |         timeMin: '2025-01-01T18:00:00Z', // 10:00 AM PST = 18:00 UTC
247 |         timeMax: '2025-01-02T02:00:00Z', // 18:00 PM PST = 02:00 UTC next day
248 |         singleEvents: true,
249 |         orderBy: 'startTime'
250 |       });
251 |     });
252 | 
253 |     it('should preserve timezone-aware timeMin/timeMax regardless of timeZone parameter', async () => {
254 |       const args = {
255 |         calendarId: 'primary',
256 |         timeMin: '2025-01-01T10:00:00-08:00',
257 |         timeMax: '2025-01-01T18:00:00-08:00',
258 |         timeZone: 'America/New_York' // Different timezone, should be ignored
259 |       };
260 | 
261 |       await handler.runTool(args, mockOAuth2Client);
262 | 
263 |       // Verify that the original timezone-aware times are preserved
264 |       expect(mockCalendar.events.list).toHaveBeenCalledWith({
265 |         calendarId: 'primary',
266 |         timeMin: '2025-01-01T10:00:00-08:00',
267 |         timeMax: '2025-01-01T18:00:00-08:00',
268 |         singleEvents: true,
269 |         orderBy: 'startTime'
270 |       });
271 |     });
272 | 
273 |     it('should fall back to calendar timezone when timeZone parameter not provided', async () => {
274 |       // Mock calendar with Los Angeles timezone
275 |       mockCalendar.calendarList.get.mockResolvedValue({
276 |         data: { timeZone: 'America/Los_Angeles' }
277 |       });
278 | 
279 |       const args = {
280 |         calendarId: 'primary',
281 |         timeMin: '2025-01-01T10:00:00',
282 |         timeMax: '2025-01-01T18:00:00'
283 |         // No timeZone parameter
284 |       };
285 | 
286 |       await handler.runTool(args, mockOAuth2Client);
287 | 
288 |       // Verify that the calendar's timezone is used for conversion
289 |       expect(mockCalendar.events.list).toHaveBeenCalledWith({
290 |         calendarId: 'primary',
291 |         timeMin: '2025-01-01T18:00:00Z', // 10:00 AM PST = 18:00 UTC
292 |         timeMax: '2025-01-02T02:00:00Z', // 18:00 PM PST = 02:00 UTC next day
293 |         singleEvents: true,
294 |         orderBy: 'startTime'
295 |       });
296 |     });
297 | 
298 |     it('should handle UTC timezone correctly', async () => {
299 |       const args = {
300 |         calendarId: 'primary',
301 |         timeMin: '2025-01-01T10:00:00',
302 |         timeMax: '2025-01-01T18:00:00',
303 |         timeZone: 'UTC'
304 |       };
305 | 
306 |       await handler.runTool(args, mockOAuth2Client);
307 | 
308 |       // Verify that UTC times are handled correctly
309 |       expect(mockCalendar.events.list).toHaveBeenCalledWith({
310 |         calendarId: 'primary',
311 |         timeMin: '2025-01-01T10:00:00Z',
312 |         timeMax: '2025-01-01T18:00:00Z',
313 |         singleEvents: true,
314 |         orderBy: 'startTime'
315 |       });
316 |     });
317 |   });
318 | });
```

--------------------------------------------------------------------------------
/src/services/conflict-detection/ConflictDetectionService.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { OAuth2Client } from "google-auth-library";
  2 | import { google, calendar_v3 } from "googleapis";
  3 | import {
  4 |   ConflictCheckResult,
  5 |   InternalConflictInfo,
  6 |   InternalDuplicateInfo,
  7 |   ConflictDetectionOptions
  8 | } from "./types.js";
  9 | import { EventSimilarityChecker } from "./EventSimilarityChecker.js";
 10 | import { ConflictAnalyzer } from "./ConflictAnalyzer.js";
 11 | import { CONFLICT_DETECTION_CONFIG } from "./config.js";
 12 | import { getEventUrl } from "../../handlers/utils.js";
 13 | import { convertToRFC3339 } from "../../handlers/utils/datetime.js";
 14 | 
 15 | /**
 16 |  * Service for detecting event conflicts and duplicates.
 17 |  * 
 18 |  * IMPORTANT: This service relies on Google Calendar's list API to find existing events.
 19 |  * Due to eventual consistency in Google Calendar, recently created events may not
 20 |  * immediately appear in list queries. This is a known limitation of the Google Calendar API
 21 |  * and affects duplicate detection for events created in quick succession.
 22 |  * 
 23 |  * In real-world usage, this is rarely an issue as there's natural time between event creation.
 24 |  */
 25 | export class ConflictDetectionService {
 26 |   private similarityChecker: EventSimilarityChecker;
 27 |   private conflictAnalyzer: ConflictAnalyzer;
 28 |   
 29 |   constructor() {
 30 |     this.similarityChecker = new EventSimilarityChecker();
 31 |     this.conflictAnalyzer = new ConflictAnalyzer();
 32 |   }
 33 | 
 34 |   /**
 35 |    * Check for conflicts and duplicates when creating or updating an event
 36 |    */
 37 |   async checkConflicts(
 38 |     oauth2Client: OAuth2Client,
 39 |     event: calendar_v3.Schema$Event,
 40 |     calendarId: string,
 41 |     options: ConflictDetectionOptions = {}
 42 |   ): Promise<ConflictCheckResult> {
 43 |     const {
 44 |       checkDuplicates = true,
 45 |       checkConflicts = true,
 46 |       calendarsToCheck = [calendarId],
 47 |       duplicateSimilarityThreshold = CONFLICT_DETECTION_CONFIG.DEFAULT_DUPLICATE_THRESHOLD,
 48 |       includeDeclinedEvents = false
 49 |     } = options;
 50 | 
 51 |     const result: ConflictCheckResult = {
 52 |       hasConflicts: false,
 53 |       conflicts: [],
 54 |       duplicates: []
 55 |     };
 56 | 
 57 |     if (!event.start || !event.end) {
 58 |       return result;
 59 |     }
 60 | 
 61 |     // Get the time range for checking
 62 |     let timeMin = event.start.dateTime || event.start.date;
 63 |     let timeMax = event.end.dateTime || event.end.date;
 64 | 
 65 |     if (!timeMin || !timeMax) {
 66 |       return result;
 67 |     }
 68 | 
 69 |     // Extract timezone if present (prefer start time's timezone)
 70 |     const timezone = event.start.timeZone || event.end.timeZone;
 71 |     
 72 |     
 73 |     // The Google Calendar API requires RFC3339 format for timeMin/timeMax
 74 |     // If we have timezone-naive datetimes with a timezone field, convert them to proper RFC3339
 75 |     // Check for minus but exclude the date separator (e.g., 2025-09-05)
 76 |     const needsConversion = timezone && timeMin && 
 77 |       !timeMin.includes('Z') && 
 78 |       !timeMin.includes('+') && 
 79 |       !timeMin.substring(10).includes('-'); // Only check for minus after the date part
 80 |       
 81 |     if (needsConversion) {
 82 |       timeMin = convertToRFC3339(timeMin, timezone);
 83 |       timeMax = convertToRFC3339(timeMax, timezone);
 84 |     }
 85 |     
 86 |     
 87 |     // Use the exact time range provided for searching
 88 |     // This ensures duplicate detection only flags events that actually overlap
 89 |     const searchTimeMin = timeMin;
 90 |     const searchTimeMax = timeMax;
 91 | 
 92 |     // Check each calendar
 93 |     for (const checkCalendarId of calendarsToCheck) {
 94 |       try {
 95 |         // Get events in the search time range, passing timezone for proper interpretation
 96 |         const events = await this.getEventsInTimeRange(
 97 |           oauth2Client,
 98 |           checkCalendarId,
 99 |           searchTimeMin,
100 |           searchTimeMax,
101 |           timezone || undefined
102 |         );
103 | 
104 |         // Check for duplicates
105 |         if (checkDuplicates) {
106 |           const duplicates = this.findDuplicates(
107 |             event,
108 |             events,
109 |             checkCalendarId,
110 |             duplicateSimilarityThreshold
111 |           );
112 |           result.duplicates.push(...duplicates);
113 |         }
114 | 
115 |         // Check for conflicts
116 |         if (checkConflicts) {
117 |           const conflicts = this.findConflicts(
118 |             event,
119 |             events,
120 |             checkCalendarId,
121 |             includeDeclinedEvents
122 |           );
123 |           result.conflicts.push(...conflicts);
124 |         }
125 |       } catch (error) {
126 |         // If we can't access a calendar, skip it silently
127 |         // Errors are expected for calendars without access permissions
128 |       }
129 |     }
130 | 
131 |     result.hasConflicts = result.conflicts.length > 0 || result.duplicates.length > 0;
132 |     return result;
133 |   }
134 | 
135 |   /**
136 |    * Get events in a specific time range from a calendar
137 |    */
138 |   private async getEventsInTimeRange(
139 |     oauth2Client: OAuth2Client,
140 |     calendarId: string,
141 |     timeMin: string,
142 |     timeMax: string,
143 |     timeZone?: string
144 |   ): Promise<calendar_v3.Schema$Event[]> {
145 |     // Fetch from API
146 |     const calendar = google.calendar({ version: "v3", auth: oauth2Client });
147 |     
148 |     // Build list parameters
149 |     const listParams: any = {
150 |       calendarId,
151 |       timeMin,
152 |       timeMax,
153 |       singleEvents: true,
154 |       orderBy: 'startTime',
155 |       maxResults: 250
156 |     };
157 |     
158 |     // The Google Calendar API accepts both:
159 |     // 1. Timezone-aware datetimes (with Z or offset)
160 |     // 2. Timezone-naive datetimes with a timeZone parameter
161 |     // We pass the timeZone parameter when available for consistency
162 |     if (timeZone) {
163 |       listParams.timeZone = timeZone;
164 |     }
165 |     
166 |     
167 |     // Use exact time range without extension to avoid false positives
168 |     const response = await calendar.events.list(listParams);
169 | 
170 |     const events = response?.data?.items || [];
171 |     
172 |     return events;
173 |   }
174 | 
175 |   /**
176 |    * Find duplicate events based on similarity
177 |    */
178 |   private findDuplicates(
179 |     newEvent: calendar_v3.Schema$Event,
180 |     existingEvents: calendar_v3.Schema$Event[],
181 |     calendarId: string,
182 |     threshold: number
183 |   ): InternalDuplicateInfo[] {
184 |     const duplicates: InternalDuplicateInfo[] = [];
185 | 
186 | 
187 |     for (const existingEvent of existingEvents) {
188 |       // Skip if it's the same event (for updates)
189 |       if (existingEvent.id === newEvent.id) continue;
190 |       
191 |       // Skip cancelled events
192 |       if (existingEvent.status === 'cancelled') continue;
193 | 
194 |       const similarity = this.similarityChecker.checkSimilarity(newEvent, existingEvent);
195 |       
196 |       
197 |       if (similarity >= threshold) {
198 |         duplicates.push({
199 |           event: {
200 |             id: existingEvent.id!,
201 |             title: existingEvent.summary || 'Untitled Event',
202 |             url: getEventUrl(existingEvent, calendarId) || undefined,
203 |             similarity: Math.round(similarity * 100) / 100
204 |           },
205 |           fullEvent: existingEvent,
206 |           calendarId: calendarId,
207 |           suggestion: similarity >= CONFLICT_DETECTION_CONFIG.DUPLICATE_THRESHOLDS.BLOCKING
208 |             ? 'This appears to be a duplicate. Consider updating the existing event instead.'
209 |             : 'This event is very similar to an existing one. Is this intentional?'
210 |         });
211 |       }
212 |     }
213 | 
214 | 
215 |     return duplicates;
216 |   }
217 | 
218 |   /**
219 |    * Find conflicting events based on time overlap
220 |    */
221 |   private findConflicts(
222 |     newEvent: calendar_v3.Schema$Event,
223 |     existingEvents: calendar_v3.Schema$Event[],
224 |     calendarId: string,
225 |     includeDeclinedEvents: boolean
226 |   ): InternalConflictInfo[] {
227 |     const conflicts: InternalConflictInfo[] = [];
228 |     const overlappingEvents = this.conflictAnalyzer.findOverlappingEvents(existingEvents, newEvent);
229 | 
230 |     for (const conflictingEvent of overlappingEvents) {
231 |       // Skip declined events if configured
232 |       if (!includeDeclinedEvents && this.isEventDeclined(conflictingEvent)) {
233 |         continue;
234 |       }
235 | 
236 |       const overlap = this.conflictAnalyzer.analyzeOverlap(newEvent, conflictingEvent);
237 |       
238 |       if (overlap.hasOverlap) {
239 |         conflicts.push({
240 |           type: 'overlap',
241 |           calendar: calendarId,
242 |           event: {
243 |             id: conflictingEvent.id!,
244 |             title: conflictingEvent.summary || 'Untitled Event',
245 |             url: getEventUrl(conflictingEvent, calendarId) || undefined,
246 |             start: conflictingEvent.start?.dateTime || conflictingEvent.start?.date || undefined,
247 |             end: conflictingEvent.end?.dateTime || conflictingEvent.end?.date || undefined
248 |           },
249 |           fullEvent: conflictingEvent,
250 |           overlap: {
251 |             duration: overlap.duration!,
252 |             percentage: overlap.percentage!,
253 |             startTime: overlap.startTime!,
254 |             endTime: overlap.endTime!
255 |           }
256 |         });
257 |       }
258 |     }
259 | 
260 |     return conflicts;
261 |   }
262 | 
263 |   /**
264 |    * Check if the current user has declined an event
265 |    */
266 |   private isEventDeclined(_event: calendar_v3.Schema$Event): boolean {
267 |     // For now, we'll skip this check since we don't have easy access to the user's email
268 |     // This could be enhanced later by passing the user email through the service
269 |     return false;
270 |   }
271 | 
272 |   /**
273 |    * Check for conflicts using free/busy data (alternative method)
274 |    */
275 |   async checkConflictsWithFreeBusy(
276 |     oauth2Client: OAuth2Client,
277 |     eventToCheck: calendar_v3.Schema$Event,
278 |     calendarsToCheck: string[]
279 |   ): Promise<InternalConflictInfo[]> {
280 |     const conflicts: InternalConflictInfo[] = [];
281 |     
282 |     if (!eventToCheck.start || !eventToCheck.end) return conflicts;
283 |     
284 |     const timeMin = eventToCheck.start.dateTime || eventToCheck.start.date;
285 |     const timeMax = eventToCheck.end.dateTime || eventToCheck.end.date;
286 |     
287 |     if (!timeMin || !timeMax) return conflicts;
288 | 
289 |     const calendar = google.calendar({ version: "v3", auth: oauth2Client });
290 |     
291 |     try {
292 |       const freeBusyResponse = await calendar.freebusy.query({
293 |         requestBody: {
294 |           timeMin,
295 |           timeMax,
296 |           items: calendarsToCheck.map(id => ({ id }))
297 |         }
298 |       });
299 | 
300 |       for (const [calendarId, calendarInfo] of Object.entries(freeBusyResponse.data.calendars || {})) {
301 |         if (calendarInfo.busy && calendarInfo.busy.length > 0) {
302 |           for (const busySlot of calendarInfo.busy) {
303 |             if (this.conflictAnalyzer.checkBusyConflict(eventToCheck, busySlot)) {
304 |               conflicts.push({
305 |                 type: 'overlap',
306 |                 calendar: calendarId,
307 |                 event: {
308 |                   id: 'busy-time',
309 |                   title: 'Busy (details unavailable)',
310 |                   start: busySlot.start || undefined,
311 |                   end: busySlot.end || undefined
312 |                 }
313 |               });
314 |             }
315 |           }
316 |         }
317 |       }
318 |     } catch (error) {
319 |       console.error('Failed to check free/busy:', error);
320 |     }
321 | 
322 |     return conflicts;
323 |   }
324 | }
```

--------------------------------------------------------------------------------
/src/tests/integration/test-data-factory.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // Test data factory utilities for integration tests
  2 | 
  3 | export interface TestEvent {
  4 |   id?: string;
  5 |   summary: string;
  6 |   description?: string;
  7 |   start: string;
  8 |   end: string;
  9 |   timeZone?: string; // Optional for all-day events
 10 |   location?: string;
 11 |   attendees?: Array<{ email: string }>;
 12 |   colorId?: string;
 13 |   reminders?: {
 14 |     useDefault: boolean;
 15 |     overrides?: Array<{ method: "email" | "popup"; minutes: number }>;
 16 |   };
 17 |   recurrence?: string[];
 18 |   modificationScope?: "thisAndFollowing" | "all" | "thisEventOnly";
 19 |   originalStartTime?: string;
 20 |   futureStartDate?: string;
 21 |   calendarId?: string;
 22 |   sendUpdates?: "all" | "externalOnly" | "none";
 23 | }
 24 | 
 25 | export interface PerformanceMetric {
 26 |   operation: string;
 27 |   startTime: number;
 28 |   endTime: number;
 29 |   duration: number;
 30 |   success: boolean;
 31 |   error?: string;
 32 | }
 33 | 
 34 | export class TestDataFactory {
 35 |   private static readonly TEST_CALENDAR_ID = process.env.TEST_CALENDAR_ID || 'primary';
 36 |   
 37 |   private createdEventIds: string[] = [];
 38 |   private performanceMetrics: PerformanceMetric[] = [];
 39 | 
 40 |   static getTestCalendarId(): string {
 41 |     return TestDataFactory.TEST_CALENDAR_ID;
 42 |   }
 43 | 
 44 |   // Helper method to format dates in RFC3339 format without milliseconds
 45 |   // For events with a timeZone field, use timezone-naive format to avoid conflicts
 46 |   public static formatDateTimeRFC3339(date: Date): string {
 47 |     const isoString = date.toISOString();
 48 |     // Return timezone-naive format (without Z suffix) to work better with timeZone field
 49 |     return isoString.replace(/\.\d{3}Z$/, '');
 50 |   }
 51 | 
 52 |   // Helper method to format dates in RFC3339 format with timezone (for search operations)
 53 |   public static formatDateTimeRFC3339WithTimezone(date: Date): string {
 54 |     return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
 55 |   }
 56 | 
 57 |   // Event data generators
 58 |   static createSingleEvent(overrides: Partial<TestEvent> = {}): TestEvent {
 59 |     const now = new Date();
 60 |     const start = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2 hours from now
 61 |     const end = new Date(start.getTime() + 60 * 60 * 1000); // 1 hour duration
 62 | 
 63 |     return {
 64 |       summary: 'Test Integration Event',
 65 |       description: 'Created by integration test suite',
 66 |       start: this.formatDateTimeRFC3339(start),
 67 |       end: this.formatDateTimeRFC3339(end),
 68 |       timeZone: 'America/Los_Angeles',
 69 |       location: 'Test Conference Room',
 70 |       reminders: {
 71 |         useDefault: false,
 72 |         overrides: [{ method: 'popup', minutes: 15 }]
 73 |       },
 74 |       ...overrides
 75 |     };
 76 |   }
 77 | 
 78 |   static createAllDayEvent(overrides: Partial<TestEvent> = {}): TestEvent {
 79 |     const tomorrow = new Date();
 80 |     tomorrow.setDate(tomorrow.getDate() + 1);
 81 |     
 82 |     const dayAfter = new Date(tomorrow);
 83 |     dayAfter.setDate(dayAfter.getDate() + 1);
 84 | 
 85 |     // For all-day events, use date-only format (YYYY-MM-DD)
 86 |     const startDate = tomorrow.toISOString().split('T')[0];
 87 |     const endDate = dayAfter.toISOString().split('T')[0];
 88 | 
 89 |     return {
 90 |       summary: 'Test All-Day Event',
 91 |       description: 'All-day test event',
 92 |       start: startDate,
 93 |       end: endDate,
 94 |       // Note: timeZone is not used for all-day events (they're date-only)
 95 |       ...overrides
 96 |     };
 97 |   }
 98 | 
 99 |   static createRecurringEvent(overrides: Partial<TestEvent> = {}): TestEvent {
100 |     const start = new Date();
101 |     start.setDate(start.getDate() + 1); // Tomorrow
102 |     start.setHours(10, 0, 0, 0); // 10 AM
103 |     
104 |     const end = new Date(start);
105 |     end.setHours(11, 0, 0, 0); // 11 AM
106 | 
107 |     return {
108 |       summary: 'Test Recurring Meeting',
109 |       description: 'Weekly recurring test meeting',
110 |       start: this.formatDateTimeRFC3339(start),
111 |       end: this.formatDateTimeRFC3339(end),
112 |       timeZone: 'America/Los_Angeles',
113 |       location: 'Recurring Meeting Room',
114 |       recurrence: ['RRULE:FREQ=WEEKLY;COUNT=5'], // 5 weeks
115 |       reminders: {
116 |         useDefault: false,
117 |         overrides: [{ method: 'email', minutes: 1440 }] // 1 day before
118 |       },
119 |       ...overrides
120 |     };
121 |   }
122 | 
123 |   static createEventWithAttendees(overrides: Partial<TestEvent> = {}): TestEvent {
124 |     const invitee1 = process.env.INVITEE_1;
125 |     const invitee2 = process.env.INVITEE_2;
126 |     
127 |     if (!invitee1 || !invitee2) {
128 |       throw new Error('INVITEE_1 and INVITEE_2 environment variables are required for creating events with attendees');
129 |     }
130 |     
131 |     return this.createSingleEvent({
132 |       summary: 'Test Meeting with Attendees',
133 |       attendees: [
134 |         { email: invitee1 },
135 |         { email: invitee2 }
136 |       ],
137 |       ...overrides
138 |     });
139 |   }
140 | 
141 |   static createColoredEvent(colorId: string, overrides: Partial<TestEvent> = {}): TestEvent {
142 |     return this.createSingleEvent({
143 |       summary: `Test Event - Color ${colorId}`,
144 |       colorId,
145 |       ...overrides
146 |     });
147 |   }
148 | 
149 |   // Time range generators
150 |   static getTimeRanges() {
151 |     const now = new Date();
152 |     
153 |     return {
154 |       // Past week
155 |       pastWeek: {
156 |         timeMin: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)),
157 |         timeMax: this.formatDateTimeRFC3339WithTimezone(now)
158 |       },
159 |       // Next week
160 |       nextWeek: {
161 |         timeMin: this.formatDateTimeRFC3339WithTimezone(now),
162 |         timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000))
163 |       },
164 |       // Next month
165 |       nextMonth: {
166 |         timeMin: this.formatDateTimeRFC3339WithTimezone(now),
167 |         timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000))
168 |       },
169 |       // Large range (3 months)
170 |       threeMonths: {
171 |         timeMin: this.formatDateTimeRFC3339WithTimezone(now),
172 |         timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000))
173 |       }
174 |     };
175 |   }
176 | 
177 |   // Performance tracking
178 |   startTimer(_operation: string): number {
179 |     return Date.now();
180 |   }
181 | 
182 |   endTimer(operation: string, startTime: number, success: boolean, error?: string): void {
183 |     const endTime = Date.now();
184 |     const duration = endTime - startTime;
185 |     
186 |     this.performanceMetrics.push({
187 |       operation,
188 |       startTime,
189 |       endTime,
190 |       duration,
191 |       success,
192 |       error
193 |     });
194 |   }
195 | 
196 |   getPerformanceMetrics(): PerformanceMetric[] {
197 |     return [...this.performanceMetrics];
198 |   }
199 | 
200 |   clearPerformanceMetrics(): void {
201 |     this.performanceMetrics = [];
202 |   }
203 | 
204 |   // Event tracking for cleanup
205 |   addCreatedEventId(eventId: string): void {
206 |     this.createdEventIds.push(eventId);
207 |   }
208 | 
209 |   getCreatedEventIds(): string[] {
210 |     return [...this.createdEventIds];
211 |   }
212 | 
213 |   clearCreatedEventIds(): void {
214 |     this.createdEventIds = [];
215 |   }
216 | 
217 |   // Search queries
218 |   static getSearchQueries() {
219 |     return [
220 |       'Test Integration',
221 |       'meeting',
222 |       'recurring',
223 |       'attendees',
224 |       'Conference Room',
225 |       'nonexistent_query_should_return_empty'
226 |     ];
227 |   }
228 | 
229 |   // Validation helpers
230 |   static validateEventResponse(response: any): boolean {
231 |     if (!response || !response.content || !Array.isArray(response.content)) {
232 |       return false;
233 |     }
234 |     
235 |     const text = response.content[0]?.text;
236 |     // Accept empty strings for search operations - they indicate "no results found"
237 |     return typeof text === 'string';
238 |   }
239 | 
240 |   static extractEventIdFromResponse(response: any): string | null {
241 |     const text = response.content[0]?.text;
242 |     if (!text) return null;
243 |     
244 |     // Try to parse as JSON first (v2.0 structured response)
245 |     try {
246 |       const parsed = JSON.parse(text);
247 |       if (parsed.event?.id) {
248 |         return parsed.event.id;
249 |       }
250 |     } catch {
251 |       // Fall back to legacy text parsing
252 |     }
253 |     
254 |     // Look for various event ID patterns in the response (legacy)
255 |     // Google Calendar event IDs can contain letters, numbers, underscores, and special characters
256 |     const patterns = [
257 |       /Event created: .* \(([^)]+)\)/, // Legacy format - Match anything within parentheses after "Event created:"
258 |       /Event updated: .* \(([^)]+)\)/, // Legacy format - Match anything within parentheses after "Event updated:"
259 |       /✅ Event created successfully[\s\S]*?([^\s\(]+) \(([^)]+)\)/, // New format - Extract ID from parentheses in event details
260 |       /✅ Event updated successfully[\s\S]*?([^\s\(]+) \(([^)]+)\)/, // New format - Extract ID from parentheses in event details
261 |       /Event ID: ([^\s]+)/, // Match non-whitespace characters after "Event ID:"
262 |       /Created event: .* \(ID: ([^)]+)\)/, // Match anything within parentheses after "ID:"
263 |       /\(([[email protected]]{10,})\)/, // Specific pattern for Google Calendar IDs with common characters
264 |     ];
265 |     
266 |     for (const pattern of patterns) {
267 |       const match = text.match(pattern);
268 |       if (match) {
269 |         // For patterns with multiple capture groups, we want the event ID
270 |         // which is typically in the last parentheses
271 |         let eventId = match[match.length - 1] || match[1];
272 |         if (eventId) {
273 |           // Clean up the captured ID (trim whitespace)
274 |           eventId = eventId.trim();
275 |           // Basic validation - should be at least 10 characters
276 |           if (eventId.length >= 10) {
277 |             return eventId;
278 |           }
279 |         }
280 |       }
281 |     }
282 |     
283 |     return null;
284 |   }
285 | 
286 |   static extractAllEventIds(response: any): string[] {
287 |     const text = response.content[0]?.text;
288 |     if (!text) return [];
289 |     
290 |     const eventIds: string[] = [];
291 |     
292 |     // Look for event IDs in list format - they appear in parentheses after event titles
293 |     // Pattern: anything that looks like an event ID in parentheses
294 |     const pattern = /\(([[email protected]]{10,})\)/g;
295 |     
296 |     let match;
297 |     while ((match = pattern.exec(text)) !== null) {
298 |       const eventId = match[1].trim();
299 |       // Basic validation - should be at least 10 characters and not contain spaces
300 |       if (eventId.length >= 10 && !eventId.includes(' ')) {
301 |         eventIds.push(eventId);
302 |       }
303 |     }
304 |     
305 |     // Also look for Event ID: patterns
306 |     const idPattern = /Event ID:\s*([[email protected]]+)/g;
307 |     while ((match = idPattern.exec(text)) !== null) {
308 |       const eventId = match[1].trim();
309 |       if (eventId.length >= 10 && !eventIds.includes(eventId)) {
310 |         eventIds.push(eventId);
311 |       }
312 |     }
313 |     
314 |     return eventIds;
315 |   }
316 | 
317 |   // Error simulation helpers
318 |   static getInvalidTestData() {
319 |     return {
320 |       invalidCalendarId: 'invalid_calendar_id',
321 |       invalidEventId: 'invalid_event_id',
322 |       invalidTimeFormat: '2024-13-45T25:99:99Z',
323 |       invalidTimezone: 'Invalid/Timezone',
324 |       invalidEmail: 'not-an-email',
325 |       invalidColorId: '999',
326 |       malformedRecurrence: ['INVALID:RRULE'],
327 |       futureDateInPast: '2020-01-01T10:00:00Z'
328 |     };
329 |   }
330 | }
```

--------------------------------------------------------------------------------
/src/handlers/core/UpdateEventHandler.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
  2 | import { OAuth2Client } from "google-auth-library";
  3 | import { UpdateEventInput } from "../../tools/registry.js";
  4 | import { BaseToolHandler } from "./BaseToolHandler.js";
  5 | import { calendar_v3 } from 'googleapis';
  6 | import { RecurringEventHelpers, RecurringEventError, RECURRING_EVENT_ERRORS } from './RecurringEventHelpers.js';
  7 | import { ConflictDetectionService } from "../../services/conflict-detection/index.js";
  8 | import { createTimeObject } from "../utils/datetime.js";
  9 | import { 
 10 |     createStructuredResponse, 
 11 |     convertConflictsToStructured,
 12 |     createWarningsArray
 13 | } from "../../utils/response-builder.js";
 14 | import { 
 15 |     UpdateEventResponse,
 16 |     convertGoogleEventToStructured 
 17 | } from "../../types/structured-responses.js";
 18 | 
 19 | export class UpdateEventHandler extends BaseToolHandler {
 20 |     private conflictDetectionService: ConflictDetectionService;
 21 |     
 22 |     constructor() {
 23 |         super();
 24 |         this.conflictDetectionService = new ConflictDetectionService();
 25 |     }
 26 |     
 27 |     async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
 28 |         const validArgs = args as UpdateEventInput;
 29 | 
 30 |         // Check for conflicts if enabled
 31 |         let conflicts = null;
 32 |         if (validArgs.checkConflicts !== false && (validArgs.start || validArgs.end)) {
 33 |             // Get the existing event to merge with updates
 34 |             const calendar = this.getCalendar(oauth2Client);
 35 |             const existingEvent = await calendar.events.get({
 36 |                 calendarId: validArgs.calendarId,
 37 |                 eventId: validArgs.eventId
 38 |             });
 39 | 
 40 |             if (!existingEvent.data) {
 41 |                 throw new Error('Event not found');
 42 |             }
 43 | 
 44 |             // Create updated event object for conflict checking
 45 |             const timezone = validArgs.timeZone || await this.getCalendarTimezone(oauth2Client, validArgs.calendarId);
 46 |             const eventToCheck: calendar_v3.Schema$Event = {
 47 |                 ...existingEvent.data,
 48 |                 id: validArgs.eventId,
 49 |                 summary: validArgs.summary || existingEvent.data.summary,
 50 |                 description: validArgs.description || existingEvent.data.description,
 51 |                 start: validArgs.start ? createTimeObject(validArgs.start, timezone) : existingEvent.data.start,
 52 |                 end: validArgs.end ? createTimeObject(validArgs.end, timezone) : existingEvent.data.end,
 53 |                 location: validArgs.location || existingEvent.data.location,
 54 |             };
 55 | 
 56 |             // Check for conflicts
 57 |             conflicts = await this.conflictDetectionService.checkConflicts(
 58 |                 oauth2Client,
 59 |                 eventToCheck,
 60 |                 validArgs.calendarId,
 61 |                 {
 62 |                     checkDuplicates: false, // Don't check duplicates for updates
 63 |                     checkConflicts: true,
 64 |                     calendarsToCheck: validArgs.calendarsToCheck || [validArgs.calendarId]
 65 |                 }
 66 |             );
 67 |         }
 68 | 
 69 |         // Update the event
 70 |         const event = await this.updateEventWithScope(oauth2Client, validArgs);
 71 | 
 72 |         // Create structured response
 73 |         const response: UpdateEventResponse = {
 74 |             event: convertGoogleEventToStructured(event, validArgs.calendarId)
 75 |         };
 76 |         
 77 |         // Add conflict information if present
 78 |         if (conflicts && conflicts.hasConflicts) {
 79 |             const structuredConflicts = convertConflictsToStructured(conflicts);
 80 |             if (structuredConflicts.conflicts) {
 81 |                 response.conflicts = structuredConflicts.conflicts;
 82 |             }
 83 |             response.warnings = createWarningsArray(conflicts);
 84 |         }
 85 |         
 86 |         return createStructuredResponse(response);
 87 |     }
 88 | 
 89 |     private async updateEventWithScope(
 90 |         client: OAuth2Client,
 91 |         args: UpdateEventInput
 92 |     ): Promise<calendar_v3.Schema$Event> {
 93 |         try {
 94 |             const calendar = this.getCalendar(client);
 95 |             const helpers = new RecurringEventHelpers(calendar);
 96 |             
 97 |             // Get calendar's default timezone if not provided
 98 |             const defaultTimeZone = await this.getCalendarTimezone(client, args.calendarId);
 99 |             
100 |             // Detect event type and validate scope usage
101 |             const eventType = await helpers.detectEventType(args.eventId, args.calendarId);
102 |             
103 |             if (args.modificationScope && args.modificationScope !== 'all' && eventType !== 'recurring') {
104 |                 throw new RecurringEventError(
105 |                     'Scope other than "all" only applies to recurring events',
106 |                     RECURRING_EVENT_ERRORS.NON_RECURRING_SCOPE
107 |                 );
108 |             }
109 |             
110 |             switch (args.modificationScope) {
111 |                 case 'thisEventOnly':
112 |                     return this.updateSingleInstance(helpers, args, defaultTimeZone);
113 |                 case 'all':
114 |                 case undefined:
115 |                     return this.updateAllInstances(helpers, args, defaultTimeZone);
116 |                 case 'thisAndFollowing':
117 |                     return this.updateFutureInstances(helpers, args, defaultTimeZone);
118 |                 default:
119 |                     throw new RecurringEventError(
120 |                         `Invalid modification scope: ${args.modificationScope}`,
121 |                         RECURRING_EVENT_ERRORS.INVALID_SCOPE
122 |                     );
123 |             }
124 |         } catch (error) {
125 |             if (error instanceof RecurringEventError) {
126 |                 throw error;
127 |             }
128 |             throw this.handleGoogleApiError(error);
129 |         }
130 |     }
131 | 
132 |     private async updateSingleInstance(
133 |         helpers: RecurringEventHelpers,
134 |         args: UpdateEventInput,
135 |         defaultTimeZone: string
136 |     ): Promise<calendar_v3.Schema$Event> {
137 |         if (!args.originalStartTime) {
138 |             throw new RecurringEventError(
139 |                 'originalStartTime is required for single instance updates',
140 |                 RECURRING_EVENT_ERRORS.MISSING_ORIGINAL_TIME
141 |             );
142 |         }
143 | 
144 |         const calendar = helpers.getCalendar();
145 |         const instanceId = helpers.formatInstanceId(args.eventId, args.originalStartTime);
146 | 
147 |         const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
148 |         const conferenceDataVersion = requestBody.conferenceData !== undefined ? 1 : undefined;
149 |         const supportsAttachments = requestBody.attachments !== undefined ? true : undefined;
150 | 
151 |         const response = await calendar.events.patch({
152 |             calendarId: args.calendarId,
153 |             eventId: instanceId,
154 |             requestBody,
155 |             ...(conferenceDataVersion && { conferenceDataVersion }),
156 |             ...(supportsAttachments && { supportsAttachments })
157 |         });
158 | 
159 |         if (!response.data) throw new Error('Failed to update event instance');
160 |         return response.data;
161 |     }
162 | 
163 |     private async updateAllInstances(
164 |         helpers: RecurringEventHelpers,
165 |         args: UpdateEventInput,
166 |         defaultTimeZone: string
167 |     ): Promise<calendar_v3.Schema$Event> {
168 |         const calendar = helpers.getCalendar();
169 | 
170 |         const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
171 |         const conferenceDataVersion = requestBody.conferenceData !== undefined ? 1 : undefined;
172 |         const supportsAttachments = requestBody.attachments !== undefined ? true : undefined;
173 | 
174 |         const response = await calendar.events.patch({
175 |             calendarId: args.calendarId,
176 |             eventId: args.eventId,
177 |             requestBody,
178 |             ...(conferenceDataVersion && { conferenceDataVersion }),
179 |             ...(supportsAttachments && { supportsAttachments })
180 |         });
181 | 
182 |         if (!response.data) throw new Error('Failed to update event');
183 |         return response.data;
184 |     }
185 | 
186 |     private async updateFutureInstances(
187 |         helpers: RecurringEventHelpers,
188 |         args: UpdateEventInput,
189 |         defaultTimeZone: string
190 |     ): Promise<calendar_v3.Schema$Event> {
191 |         if (!args.futureStartDate) {
192 |             throw new RecurringEventError(
193 |                 'futureStartDate is required for future instance updates',
194 |                 RECURRING_EVENT_ERRORS.MISSING_FUTURE_DATE
195 |             );
196 |         }
197 | 
198 |         const calendar = helpers.getCalendar();
199 |         const effectiveTimeZone = args.timeZone || defaultTimeZone;
200 | 
201 |         // 1. Get original event
202 |         const originalResponse = await calendar.events.get({
203 |             calendarId: args.calendarId,
204 |             eventId: args.eventId
205 |         });
206 |         const originalEvent = originalResponse.data;
207 | 
208 |         if (!originalEvent.recurrence) {
209 |             throw new Error('Event does not have recurrence rules');
210 |         }
211 | 
212 |         // 2. Calculate UNTIL date and update original event
213 |         const untilDate = helpers.calculateUntilDate(args.futureStartDate);
214 |         const updatedRecurrence = helpers.updateRecurrenceWithUntil(originalEvent.recurrence, untilDate);
215 | 
216 |         await calendar.events.patch({
217 |             calendarId: args.calendarId,
218 |             eventId: args.eventId,
219 |             requestBody: { recurrence: updatedRecurrence }
220 |         });
221 | 
222 |         // 3. Create new recurring event starting from future date
223 |         const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
224 |         
225 |         // Calculate end time if start time is changing
226 |         let endTime = args.end;
227 |         if (args.start || args.futureStartDate) {
228 |             const newStartTime = args.start || args.futureStartDate;
229 |             endTime = endTime || helpers.calculateEndTime(newStartTime, originalEvent);
230 |         }
231 | 
232 |         const newEvent = {
233 |             ...helpers.cleanEventForDuplication(originalEvent),
234 |             ...requestBody,
235 |             start: { 
236 |                 dateTime: args.start || args.futureStartDate, 
237 |                 timeZone: effectiveTimeZone 
238 |             },
239 |             end: { 
240 |                 dateTime: endTime, 
241 |                 timeZone: effectiveTimeZone 
242 |             }
243 |         };
244 | 
245 |         const conferenceDataVersion = newEvent.conferenceData !== undefined ? 1 : undefined;
246 |         const supportsAttachments = newEvent.attachments !== undefined ? true : undefined;
247 | 
248 |         const response = await calendar.events.insert({
249 |             calendarId: args.calendarId,
250 |             requestBody: newEvent,
251 |             ...(conferenceDataVersion && { conferenceDataVersion }),
252 |             ...(supportsAttachments && { supportsAttachments })
253 |         });
254 | 
255 |         if (!response.data) throw new Error('Failed to create new recurring event');
256 |         return response.data;
257 |     }
258 | 
259 | }
260 | 
```

--------------------------------------------------------------------------------
/src/handlers/utils.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { calendar_v3 } from "googleapis";
  2 | import { ConflictCheckResult } from "../services/conflict-detection/types.js";
  3 | 
  4 | /**
  5 |  * Generates a Google Calendar event view URL
  6 |  */
  7 | export function generateEventUrl(calendarId: string, eventId: string): string {
  8 |     const encodedCalendarId = encodeURIComponent(calendarId);
  9 |     const encodedEventId = encodeURIComponent(eventId);
 10 |     return `https://calendar.google.com/calendar/event?eid=${encodedEventId}&cid=${encodedCalendarId}`;
 11 | }
 12 | 
 13 | /**
 14 |  * Gets the URL for a calendar event
 15 |  */
 16 | export function getEventUrl(event: calendar_v3.Schema$Event, calendarId?: string): string | null {
 17 |     if (event.htmlLink) {
 18 |         return event.htmlLink;
 19 |     } else if (calendarId && event.id) {
 20 |         return generateEventUrl(calendarId, event.id);
 21 |     }
 22 |     return null;
 23 | }
 24 | 
 25 | /**
 26 |  * Formats a date/time with timezone abbreviation
 27 |  */
 28 | function formatDateTime(dateTime?: string | null, date?: string | null, timeZone?: string): string {
 29 |     if (!dateTime && !date) return "unspecified";
 30 |     
 31 |     try {
 32 |         const dt = dateTime || date;
 33 |         if (!dt) return "unspecified";
 34 |         
 35 |         // If it's a date-only event (all-day), handle it specially
 36 |         if (date && !dateTime) {
 37 |             // For all-day events, just format the date string directly
 38 |             // Date-only strings like "2025-03-15" should be displayed as-is
 39 |             const [year, month, day] = date.split('-').map(Number);
 40 |             
 41 |             // Create a date string without any timezone conversion
 42 |             const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 
 43 |                               'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
 44 |             const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 45 |             
 46 |             // Calculate day of week using Zeller's congruence (timezone-independent)
 47 |             const q = day;
 48 |             const m = month <= 2 ? month + 12 : month;
 49 |             const y = month <= 2 ? year - 1 : year;
 50 |             const k = y % 100;
 51 |             const j = Math.floor(y / 100);
 52 |             const h = (q + Math.floor((13 * (m + 1)) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) - 2 * j) % 7;
 53 |             const dayOfWeek = (h + 6) % 7; // Convert to 0=Sunday format
 54 |             
 55 |             return `${dayNames[dayOfWeek]}, ${monthNames[month - 1]} ${day}, ${year}`;
 56 |         }
 57 |         
 58 |         const parsedDate = new Date(dt);
 59 |         if (isNaN(parsedDate.getTime())) return dt;
 60 |         
 61 |         // For timed events, include timezone
 62 |         const options: Intl.DateTimeFormatOptions = {
 63 |             weekday: 'short',
 64 |             year: 'numeric',
 65 |             month: 'short',
 66 |             day: 'numeric',
 67 |             hour: 'numeric',
 68 |             minute: '2-digit',
 69 |             timeZoneName: 'short'
 70 |         };
 71 |         
 72 |         if (timeZone) {
 73 |             options.timeZone = timeZone;
 74 |         }
 75 |         
 76 |         return parsedDate.toLocaleString('en-US', options);
 77 |     } catch (error) {
 78 |         return dateTime || date || "unspecified";
 79 |     }
 80 | }
 81 | 
 82 | /**
 83 |  * Formats attendees with their response status
 84 |  */
 85 | function formatAttendees(attendees?: calendar_v3.Schema$EventAttendee[]): string {
 86 |     if (!attendees || attendees.length === 0) return "";
 87 |     
 88 |     const formatted = attendees.map(attendee => {
 89 |         const email = attendee.email || "unknown";
 90 |         const name = attendee.displayName || email;
 91 |         const status = attendee.responseStatus || "unknown";
 92 |         
 93 |         const statusText = {
 94 |             'accepted': 'accepted',
 95 |             'declined': 'declined', 
 96 |             'tentative': 'tentative',
 97 |             'needsAction': 'pending'
 98 |         }[status] || 'unknown';
 99 |         
100 |         return `${name} (${statusText})`;
101 |     }).join(", ");
102 |     
103 |     return `\nGuests: ${formatted}`;
104 | }
105 | 
106 | /**
107 |  * Formats a single event with rich details
108 |  */
109 | export function formatEventWithDetails(event: calendar_v3.Schema$Event, calendarId?: string): string {
110 |     const title = event.summary ? `Event: ${event.summary}` : "Untitled Event";
111 |     const eventId = event.id ? `\nEvent ID: ${event.id}` : "";
112 |     const description = event.description ? `\nDescription: ${event.description}` : "";
113 |     const location = event.location ? `\nLocation: ${event.location}` : "";
114 |     const colorId = event.colorId ? `\nColor ID: ${event.colorId}` : "";
115 | 
116 |     // Format start and end times with timezone
117 |     const startTime = formatDateTime(event.start?.dateTime, event.start?.date, event.start?.timeZone || undefined);
118 |     const endTime = formatDateTime(event.end?.dateTime, event.end?.date, event.end?.timeZone || undefined);
119 |     
120 |     let timeInfo: string;
121 |     if (event.start?.date) {
122 |         // All-day event
123 |         if (event.start.date === event.end?.date) {
124 |             // Single day all-day event
125 |             timeInfo = `\nDate: ${startTime}`;
126 |         } else {
127 |             // Multi-day all-day event - end date is exclusive, so subtract 1 day for display
128 |             if (event.end?.date) {
129 |                 // Parse the end date properly without timezone conversion
130 |                 const [year, month, day] = event.end.date.split('-').map(Number);
131 |                 
132 |                 // Subtract 1 day since end is exclusive, handling month/year boundaries
133 |                 let adjustedDay = day - 1;
134 |                 let adjustedMonth = month;
135 |                 let adjustedYear = year;
136 |                 
137 |                 if (adjustedDay < 1) {
138 |                     adjustedMonth--;
139 |                     if (adjustedMonth < 1) {
140 |                         adjustedMonth = 12;
141 |                         adjustedYear--;
142 |                     }
143 |                     // Get days in the previous month
144 |                     const daysInMonth = new Date(adjustedYear, adjustedMonth, 0).getDate();
145 |                     adjustedDay = daysInMonth;
146 |                 }
147 |                 
148 |                 // Format without using Date object to avoid timezone issues
149 |                 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 
150 |                                   'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
151 |                 const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
152 |                 
153 |                 // Calculate day of week using Zeller's congruence
154 |                 const q = adjustedDay;
155 |                 const m = adjustedMonth <= 2 ? adjustedMonth + 12 : adjustedMonth;
156 |                 const y = adjustedMonth <= 2 ? adjustedYear - 1 : adjustedYear;
157 |                 const k = y % 100;
158 |                 const j = Math.floor(y / 100);
159 |                 const h = (q + Math.floor((13 * (m + 1)) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) - 2 * j) % 7;
160 |                 const dayOfWeek = (h + 6) % 7; // Convert to 0=Sunday format
161 |                 
162 |                 const adjustedEndTime = `${dayNames[dayOfWeek]}, ${monthNames[adjustedMonth - 1]} ${adjustedDay}, ${adjustedYear}`;
163 |                 timeInfo = `\nStart Date: ${startTime}\nEnd Date: ${adjustedEndTime}`;
164 |             } else {
165 |                 timeInfo = `\nStart Date: ${startTime}`;
166 |             }
167 |         }
168 |     } else {
169 |         // Timed event
170 |         timeInfo = `\nStart: ${startTime}\nEnd: ${endTime}`;
171 |     }
172 |     
173 |     const attendeeInfo = formatAttendees(event.attendees);
174 |     
175 |     const eventUrl = getEventUrl(event, calendarId);
176 |     const urlInfo = eventUrl ? `\nView: ${eventUrl}` : "";
177 |     
178 |     return `${title}${eventId}${description}${timeInfo}${location}${colorId}${attendeeInfo}${urlInfo}`;
179 | }
180 | 
181 | /**
182 |  * Formats conflict check results for display
183 |  */
184 | export function formatConflictWarnings(conflicts: ConflictCheckResult): string {
185 |     if (!conflicts.hasConflicts) return "";
186 |     
187 |     let warnings = "";
188 |     
189 |     // Format duplicate warnings
190 |     if (conflicts.duplicates.length > 0) {
191 |         warnings += "\n\n⚠️ POTENTIAL DUPLICATES DETECTED:";
192 |         for (const dup of conflicts.duplicates) {
193 |             warnings += `\n\n━━━ Duplicate Event (${Math.round(dup.event.similarity * 100)}% similar) ━━━`;
194 |             warnings += `\n${dup.suggestion}`;
195 |             
196 |             // Show full event details if available
197 |             if (dup.fullEvent) {
198 |                 warnings += `\n\nExisting event details:`;
199 |                 warnings += `\n${formatEventWithDetails(dup.fullEvent, dup.calendarId)}`;
200 |             } else {
201 |                 // Fallback to basic info
202 |                 warnings += `\n• "${dup.event.title}"`;
203 |                 if (dup.event.url) {
204 |                     warnings += `\n  View existing event: ${dup.event.url}`;
205 |                 }
206 |             }
207 |         }
208 |     }
209 |     
210 |     // Format conflict warnings
211 |     if (conflicts.conflicts.length > 0) {
212 |         warnings += "\n\n⚠️ SCHEDULING CONFLICTS DETECTED:";
213 |         const conflictsByCalendar = conflicts.conflicts.reduce((acc, conflict) => {
214 |             if (!acc[conflict.calendar]) acc[conflict.calendar] = [];
215 |             acc[conflict.calendar].push(conflict);
216 |             return acc;
217 |         }, {} as Record<string, typeof conflicts.conflicts>);
218 |         
219 |         for (const [calendar, calendarConflicts] of Object.entries(conflictsByCalendar)) {
220 |             warnings += `\n\nCalendar: ${calendar}`;
221 |             for (const conflict of calendarConflicts) {
222 |                 warnings += `\n\n━━━ Conflicting Event ━━━`;
223 |                 if (conflict.overlap) {
224 |                     warnings += `\n⚠️  Overlap: ${conflict.overlap.duration} (${conflict.overlap.percentage}% of your event)`;
225 |                 }
226 |                 
227 |                 // Show full event details if available
228 |                 if (conflict.fullEvent) {
229 |                     warnings += `\n\nConflicting event details:`;
230 |                     warnings += `\n${formatEventWithDetails(conflict.fullEvent, calendar)}`;
231 |                 } else {
232 |                     // Fallback to basic info
233 |                     warnings += `\n• Conflicts with "${conflict.event.title}"`;
234 |                     if (conflict.event.start && conflict.event.end) {
235 |                         const start = formatDateTime(conflict.event.start);
236 |                         const end = formatDateTime(conflict.event.end);
237 |                         warnings += `\n  Time: ${start} - ${end}`;
238 |                     }
239 |                     if (conflict.event.url) {
240 |                         warnings += `\n  View event: ${conflict.event.url}`;
241 |                     }
242 |                 }
243 |             }
244 |         }
245 |     }
246 |     
247 |     return warnings;
248 | }
249 | 
250 | /**
251 |  * Creates a response with event details and optional conflict warnings
252 |  */
253 | export function createEventResponseWithConflicts(
254 |     event: calendar_v3.Schema$Event,
255 |     calendarId: string,
256 |     conflicts?: ConflictCheckResult,
257 |     actionVerb: string = "created"
258 | ): string {
259 |     const eventDetails = formatEventWithDetails(event, calendarId);
260 |     const conflictWarnings = conflicts ? formatConflictWarnings(conflicts) : "";
261 |     
262 |     const successMessage = conflicts?.hasConflicts 
263 |         ? `Event ${actionVerb} with warnings!`
264 |         : `Event ${actionVerb} successfully!`;
265 |     
266 |     return `${successMessage}\n\n${eventDetails}${conflictWarnings}`;
267 | }
268 | 
269 | 
```

--------------------------------------------------------------------------------
/src/tests/unit/schemas/schema-compatibility.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect } from 'vitest';
  2 | import { ToolRegistry } from '../../../tools/registry.js';
  3 | 
  4 | /**
  5 |  * Provider-Specific Schema Compatibility Tests
  6 |  *
  7 |  * These tests ensure that schemas are compatible with different MCP clients
  8 |  * by testing what each provider actually receives, not internal implementation.
  9 |  *
 10 |  * - OpenAI: Receives converted schemas (anyOf flattened to string)
 11 |  * - Python MCP: Receives raw schemas (anyOf preserved for native array support)
 12 |  * - Claude: Uses raw MCP schemas
 13 |  */
 14 | 
 15 | // Type for JSON Schema objects (subset of what zod-to-json-schema returns)
 16 | interface JSONSchemaObject {
 17 |   type?: string;
 18 |   properties?: Record<string, any>;
 19 |   required?: string[];
 20 |   anyOf?: any[];
 21 |   [key: string]: any;
 22 | }
 23 | 
 24 | describe('Provider-Specific Schema Compatibility', () => {
 25 |   describe('OpenAI Schema Compatibility', () => {
 26 |     // Helper function that mimics OpenAI schema conversion from openai-mcp-integration.test.ts
 27 |     const convertMCPSchemaToOpenAI = (mcpSchema: any): any => {
 28 |       if (!mcpSchema) {
 29 |         return {
 30 |           type: 'object',
 31 |           properties: {},
 32 |           required: []
 33 |         };
 34 |       }
 35 | 
 36 |       return {
 37 |         type: 'object',
 38 |         properties: enhancePropertiesForOpenAI(mcpSchema.properties || {}),
 39 |         required: mcpSchema.required || []
 40 |       };
 41 |     };
 42 | 
 43 |     const enhancePropertiesForOpenAI = (properties: any): any => {
 44 |       const enhanced: any = {};
 45 | 
 46 |       for (const [key, value] of Object.entries(properties)) {
 47 |         const prop = value as any;
 48 |         enhanced[key] = { ...prop };
 49 | 
 50 |         // Handle anyOf union types (OpenAI doesn't support these well)
 51 |         if (prop.anyOf && Array.isArray(prop.anyOf)) {
 52 |           const stringType = prop.anyOf.find((t: any) => t.type === 'string');
 53 |           if (stringType) {
 54 |             enhanced[key] = {
 55 |               type: 'string',
 56 |               description: `${stringType.description || prop.description || ''} Note: For multiple values, use JSON array string format: '["id1", "id2"]'`.trim()
 57 |             };
 58 |           } else {
 59 |             enhanced[key] = { ...prop.anyOf[0] };
 60 |           }
 61 |           delete enhanced[key].anyOf;
 62 |         }
 63 | 
 64 |         // Recursively enhance nested objects
 65 |         if (enhanced[key].type === 'object' && enhanced[key].properties) {
 66 |           enhanced[key].properties = enhancePropertiesForOpenAI(enhanced[key].properties);
 67 |         }
 68 | 
 69 |         // Enhance array items if they contain objects
 70 |         if (enhanced[key].type === 'array' && enhanced[key].items && enhanced[key].items.properties) {
 71 |           enhanced[key].items = {
 72 |             ...enhanced[key].items,
 73 |             properties: enhancePropertiesForOpenAI(enhanced[key].items.properties)
 74 |           };
 75 |         }
 76 |       }
 77 | 
 78 |       return enhanced;
 79 |     };
 80 | 
 81 |     it('should ensure ALL tools (including list-events) have no problematic features after OpenAI conversion', () => {
 82 |       const tools = ToolRegistry.getToolsWithSchemas();
 83 |       const problematicFeatures = ['oneOf', 'anyOf', 'allOf', 'not'];
 84 |       const issues: string[] = [];
 85 | 
 86 |       for (const tool of tools) {
 87 |         // Convert to OpenAI format (this is what OpenAI actually sees)
 88 |         const openaiSchema = convertMCPSchemaToOpenAI(tool.inputSchema);
 89 |         const schemaStr = JSON.stringify(openaiSchema);
 90 | 
 91 |         for (const feature of problematicFeatures) {
 92 |           if (schemaStr.includes(`"${feature}"`)) {
 93 |             issues.push(`Tool "${tool.name}" contains "${feature}" after OpenAI conversion - this will break OpenAI function calling`);
 94 |           }
 95 |         }
 96 |       }
 97 | 
 98 |       if (issues.length > 0) {
 99 |         throw new Error(`OpenAI schema compatibility issues found:\n${issues.join('\n')}`);
100 |       }
101 |     });
102 | 
103 |     it('should convert list-events calendarId anyOf to string for OpenAI', () => {
104 |       const tools = ToolRegistry.getToolsWithSchemas();
105 |       const listEventsTool = tools.find(t => t.name === 'list-events');
106 | 
107 |       expect(listEventsTool).toBeDefined();
108 | 
109 |       // Convert to OpenAI format
110 |       const openaiSchema = convertMCPSchemaToOpenAI(listEventsTool!.inputSchema);
111 | 
112 |       // OpenAI should see a simple string type, not anyOf
113 |       expect(openaiSchema.properties.calendarId.type).toBe('string');
114 |       expect(openaiSchema.properties.calendarId.anyOf).toBeUndefined();
115 | 
116 |       // Description should mention JSON array format
117 |       expect(openaiSchema.properties.calendarId.description).toContain('JSON array string format');
118 |       expect(openaiSchema.properties.calendarId.description).toMatch(/\[".*"\]/);
119 |     });
120 | 
121 |     it('should ensure all converted schemas are valid objects', () => {
122 |       const tools = ToolRegistry.getToolsWithSchemas();
123 | 
124 |       for (const tool of tools) {
125 |         const openaiSchema = convertMCPSchemaToOpenAI(tool.inputSchema);
126 | 
127 |         expect(openaiSchema.type).toBe('object');
128 |         expect(openaiSchema.properties).toBeDefined();
129 |         expect(openaiSchema.required).toBeDefined();
130 |       }
131 |     });
132 |   });
133 | 
134 |   describe('Python MCP Client Compatibility', () => {
135 |     it('should ensure list-events supports native arrays via anyOf', () => {
136 |       const tools = ToolRegistry.getToolsWithSchemas();
137 |       const listEventsTool = tools.find(t => t.name === 'list-events');
138 | 
139 |       expect(listEventsTool).toBeDefined();
140 | 
141 |       // Raw MCP schema should have anyOf for Python clients
142 |       const schema = listEventsTool!.inputSchema as JSONSchemaObject;
143 |       expect(schema.properties).toBeDefined();
144 | 
145 |       const calendarIdProp = schema.properties!.calendarId;
146 |       expect(calendarIdProp.anyOf).toBeDefined();
147 |       expect(Array.isArray(calendarIdProp.anyOf)).toBe(true);
148 |       expect(calendarIdProp.anyOf.length).toBe(2);
149 | 
150 |       // Verify it has both string and array options
151 |       const types = calendarIdProp.anyOf.map((t: any) => t.type);
152 |       expect(types).toContain('string');
153 |       expect(types).toContain('array');
154 |     });
155 | 
156 |     it('should ensure all other tools do NOT use anyOf/oneOf/allOf', () => {
157 |       const tools = ToolRegistry.getToolsWithSchemas();
158 |       const problematicFeatures = ['oneOf', 'anyOf', 'allOf', 'not'];
159 |       const issues: string[] = [];
160 | 
161 |       for (const tool of tools) {
162 |         // Skip list-events - it's explicitly allowed to use anyOf
163 |         if (tool.name === 'list-events') {
164 |           continue;
165 |         }
166 | 
167 |         const schemaStr = JSON.stringify(tool.inputSchema);
168 | 
169 |         for (const feature of problematicFeatures) {
170 |           if (schemaStr.includes(`"${feature}"`)) {
171 |             issues.push(`Tool "${tool.name}" contains problematic feature: ${feature}`);
172 |           }
173 |         }
174 |       }
175 | 
176 |       if (issues.length > 0) {
177 |         throw new Error(`Raw MCP schema compatibility issues found:\n${issues.join('\n')}`);
178 |       }
179 |     });
180 |   });
181 | 
182 |   describe('General Schema Structure', () => {
183 |     it('should have tools available', () => {
184 |       const tools = ToolRegistry.getToolsWithSchemas();
185 |       expect(tools).toBeDefined();
186 |       expect(tools.length).toBeGreaterThan(0);
187 |     });
188 | 
189 |     it('should have proper schema structure for all tools', () => {
190 |       const tools = ToolRegistry.getToolsWithSchemas();
191 |       expect(tools).toBeDefined();
192 |       expect(tools.length).toBeGreaterThan(0);
193 | 
194 |       for (const tool of tools) {
195 |         const schema = tool.inputSchema as JSONSchemaObject;
196 | 
197 |         // All schemas should be objects at the top level
198 |         expect(schema.type).toBe('object');
199 |       }
200 |     });
201 | 
202 |     it('should validate specific known tool schemas exist', () => {
203 |       const tools = ToolRegistry.getToolsWithSchemas();
204 |       const toolSchemas = new Map();
205 |       for (const tool of tools) {
206 |         toolSchemas.set(tool.name, tool.inputSchema);
207 |       }
208 | 
209 |       // Validate that key tools exist and have the proper basic structure
210 |       const listEventsSchema = toolSchemas.get('list-events') as JSONSchemaObject;
211 |       expect(listEventsSchema).toBeDefined();
212 |       expect(listEventsSchema.type).toBe('object');
213 | 
214 |       if (listEventsSchema.properties) {
215 |         expect(listEventsSchema.properties.calendarId).toBeDefined();
216 |         expect(listEventsSchema.properties.timeMin).toBeDefined();
217 |         expect(listEventsSchema.properties.timeMax).toBeDefined();
218 |       }
219 | 
220 |       // Check other important tools exist
221 |       expect(toolSchemas.get('create-event')).toBeDefined();
222 |       expect(toolSchemas.get('update-event')).toBeDefined();
223 |       expect(toolSchemas.get('delete-event')).toBeDefined();
224 |     });
225 | 
226 |     it('should test that all datetime fields have proper format', () => {
227 |       const tools = ToolRegistry.getToolsWithSchemas();
228 | 
229 |       const toolsWithDateTimeFields = ['list-events', 'search-events', 'create-event', 'update-event', 'get-freebusy'];
230 | 
231 |       for (const tool of tools) {
232 |         if (toolsWithDateTimeFields.includes(tool.name)) {
233 |           // These tools should exist and be properly typed
234 |           const schema = tool.inputSchema as JSONSchemaObject;
235 |           expect(schema.type).toBe('object');
236 |         }
237 |       }
238 |     });
239 | 
240 |     it('should ensure enum fields are properly structured', () => {
241 |       const tools = ToolRegistry.getToolsWithSchemas();
242 | 
243 |       const toolsWithEnums = ['update-event', 'delete-event'];
244 | 
245 |       for (const tool of tools) {
246 |         if (toolsWithEnums.includes(tool.name)) {
247 |           // These tools should exist and be properly typed
248 |           const schema = tool.inputSchema as JSONSchemaObject;
249 |           expect(schema.type).toBe('object');
250 |         }
251 |       }
252 |     });
253 | 
254 |     it('should validate array fields have proper items definition', () => {
255 |       const tools = ToolRegistry.getToolsWithSchemas();
256 | 
257 |       const toolsWithArrays = ['create-event', 'update-event', 'get-freebusy'];
258 | 
259 |       for (const tool of tools) {
260 |         if (toolsWithArrays.includes(tool.name)) {
261 |           // These tools should exist and be properly typed
262 |           const schema = tool.inputSchema as JSONSchemaObject;
263 |           expect(schema.type).toBe('object');
264 |         }
265 |       }
266 |     });
267 |   });
268 | });
269 | 
270 | /**
271 |  * Schema Validation Rules Documentation
272 |  *
273 |  * This test documents the rules that our schemas must follow
274 |  * to be compatible with various MCP clients.
275 |  */
276 | describe('Schema Validation Rules Documentation', () => {
277 |   it('should document provider-specific compatibility requirements', () => {
278 |     const rules = {
279 |       'OpenAI': 'Schemas are converted to remove anyOf/oneOf/allOf. Union types flattened to primary type with usage notes in description.',
280 |       'Python MCP': 'Native array support via anyOf for list-events.calendarId. Accepts both string and array types directly.',
281 |       'Claude/Generic MCP': 'Uses raw schemas. list-events has anyOf for flexibility, but most tools avoid union types for broad compatibility.',
282 |       'Top-level schema': 'All schemas must be type: "object" at root level.',
283 |       'DateTime fields': 'Support both RFC3339 with timezone and timezone-naive formats.',
284 |       'Array fields': 'Must have items schema defined for proper validation.',
285 |       'Enum fields': 'Must include type information alongside enum values.'
286 |     };
287 | 
288 |     // This test documents the rules - it always passes but serves as documentation
289 |     expect(Object.keys(rules).length).toBeGreaterThan(0);
290 |   });
291 | });
292 | 
```

--------------------------------------------------------------------------------
/src/auth/server.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { OAuth2Client } from 'google-auth-library';
  2 | import { TokenManager } from './tokenManager.js';
  3 | import http from 'http';
  4 | import { URL } from 'url';
  5 | import open from 'open';
  6 | import { loadCredentials } from './client.js';
  7 | import { getAccountMode } from './utils.js';
  8 | 
  9 | export class AuthServer {
 10 |   private baseOAuth2Client: OAuth2Client; // Used by TokenManager for validation/refresh
 11 |   private flowOAuth2Client: OAuth2Client | null = null; // Used specifically for the auth code flow
 12 |   private server: http.Server | null = null;
 13 |   private tokenManager: TokenManager;
 14 |   private portRange: { start: number; end: number };
 15 |   private activeConnections: Set<import('net').Socket> = new Set(); // Track active socket connections
 16 |   public authCompletedSuccessfully = false; // Flag for standalone script
 17 | 
 18 |   constructor(oauth2Client: OAuth2Client) {
 19 |     this.baseOAuth2Client = oauth2Client;
 20 |     this.tokenManager = new TokenManager(oauth2Client);
 21 |     this.portRange = { start: 3500, end: 3505 };
 22 |   }
 23 | 
 24 |   private createServer(): http.Server {
 25 |     const server = http.createServer(async (req, res) => {
 26 |       const url = new URL(req.url || '/', `http://${req.headers.host}`);
 27 |       
 28 |       if (url.pathname === '/') {
 29 |         // Root route - show auth link
 30 |         const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client;
 31 |         const scopes = ['https://www.googleapis.com/auth/calendar'];
 32 |         const authUrl = clientForUrl.generateAuthUrl({
 33 |           access_type: 'offline',
 34 |           scope: scopes,
 35 |           prompt: 'consent'
 36 |         });
 37 |         
 38 |         const accountMode = getAccountMode();
 39 |         
 40 |         res.writeHead(200, { 'Content-Type': 'text/html' });
 41 |         res.end(`
 42 |           <h1>Google Calendar Authentication</h1>
 43 |           <p><strong>Account Mode:</strong> <code>${accountMode}</code></p>
 44 |           <p>You are authenticating for the <strong>${accountMode}</strong> account.</p>
 45 |           <a href="${authUrl}">Authenticate with Google</a>
 46 |         `);
 47 |         
 48 |       } else if (url.pathname === '/oauth2callback') {
 49 |         // OAuth callback route
 50 |         const code = url.searchParams.get('code');
 51 |         if (!code) {
 52 |           res.writeHead(400, { 'Content-Type': 'text/plain' });
 53 |           res.end('Authorization code missing');
 54 |           return;
 55 |         }
 56 |         
 57 |         if (!this.flowOAuth2Client) {
 58 |           res.writeHead(500, { 'Content-Type': 'text/plain' });
 59 |           res.end('Authentication flow not properly initiated.');
 60 |           return;
 61 |         }
 62 |         
 63 |         try {
 64 |           const { tokens } = await this.flowOAuth2Client.getToken(code);
 65 |           await this.tokenManager.saveTokens(tokens);
 66 |           this.authCompletedSuccessfully = true;
 67 | 
 68 |           const tokenPath = this.tokenManager.getTokenPath();
 69 |           const accountMode = this.tokenManager.getAccountMode();
 70 |           
 71 |           res.writeHead(200, { 'Content-Type': 'text/html' });
 72 |           res.end(`
 73 |             <!DOCTYPE html>
 74 |             <html lang="en">
 75 |             <head>
 76 |                 <meta charset="UTF-8">
 77 |                 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 78 |                 <title>Authentication Successful</title>
 79 |                 <style>
 80 |                     body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; }
 81 |                     .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
 82 |                     h1 { color: #4CAF50; }
 83 |                     p { color: #333; margin-bottom: 0.5em; }
 84 |                     code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
 85 |                     .account-mode { background-color: #e3f2fd; padding: 1em; border-radius: 5px; margin: 1em 0; }
 86 |                 </style>
 87 |             </head>
 88 |             <body>
 89 |                 <div class="container">
 90 |                     <h1>Authentication Successful!</h1>
 91 |                     <div class="account-mode">
 92 |                         <p><strong>Account Mode:</strong> <code>${accountMode}</code></p>
 93 |                         <p>Your authentication tokens have been saved for the <strong>${accountMode}</strong> account.</p>
 94 |                     </div>
 95 |                     <p>Tokens saved to:</p>
 96 |                     <p><code>${tokenPath}</code></p>
 97 |                     <p>You can now close this browser window.</p>
 98 |                 </div>
 99 |             </body>
100 |             </html>
101 |           `);
102 |         } catch (error: unknown) {
103 |           this.authCompletedSuccessfully = false;
104 |           const message = error instanceof Error ? error.message : 'Unknown error';
105 |           process.stderr.write(`✗ Token save failed: ${message}\n`);
106 | 
107 |           res.writeHead(500, { 'Content-Type': 'text/html' });
108 |           res.end(`
109 |             <!DOCTYPE html>
110 |             <html lang="en">
111 |             <head>
112 |                 <meta charset="UTF-8">
113 |                 <meta name="viewport" content="width=device-width, initial-scale=1.0">
114 |                 <title>Authentication Failed</title>
115 |                 <style>
116 |                     body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; }
117 |                     .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
118 |                     h1 { color: #F44336; }
119 |                     p { color: #333; }
120 |                 </style>
121 |             </head>
122 |             <body>
123 |                 <div class="container">
124 |                     <h1>Authentication Failed</h1>
125 |                     <p>An error occurred during authentication:</p>
126 |                     <p><code>${message}</code></p>
127 |                     <p>Please try again or check the server logs.</p>
128 |                 </div>
129 |             </body>
130 |             </html>
131 |           `);
132 |         }
133 |       } else {
134 |         // 404 for other routes
135 |         res.writeHead(404, { 'Content-Type': 'text/plain' });
136 |         res.end('Not Found');
137 |       }
138 |     });
139 | 
140 |     // Track connections at server level
141 |     server.on('connection', (socket) => {
142 |       this.activeConnections.add(socket);
143 |       socket.on('close', () => {
144 |         this.activeConnections.delete(socket);
145 |       });
146 |     });
147 |     
148 |     return server;
149 |   }
150 | 
151 |   async start(openBrowser = true): Promise<boolean> {
152 |     // Add timeout wrapper to prevent hanging
153 |     return Promise.race([
154 |       this.startWithTimeout(openBrowser),
155 |       new Promise<boolean>((_, reject) => {
156 |         setTimeout(() => reject(new Error('Auth server start timed out after 10 seconds')), 10000);
157 |       })
158 |     ]).catch(() => false); // Return false on timeout instead of throwing
159 |   }
160 | 
161 |   private async startWithTimeout(openBrowser = true): Promise<boolean> {
162 |     if (await this.tokenManager.validateTokens()) {
163 |       this.authCompletedSuccessfully = true;
164 |       return true;
165 |     }
166 |     
167 |     // Try to start the server and get the port
168 |     const port = await this.startServerOnAvailablePort();
169 |     if (port === null) {
170 |       process.stderr.write(`Could not start auth server on available port. Please check port availability (${this.portRange.start}-${this.portRange.end}) and try again.\n`);
171 | 
172 |       this.authCompletedSuccessfully = false;
173 |       return false;
174 |     }
175 | 
176 |     // Successfully started server on `port`. Now create the flow-specific OAuth client.
177 |     try {
178 |       const { client_id, client_secret } = await loadCredentials();
179 |       this.flowOAuth2Client = new OAuth2Client(
180 |         client_id,
181 |         client_secret,
182 |         `http://localhost:${port}/oauth2callback`
183 |       );
184 |     } catch (error) {
185 |         // Could not load credentials, cannot proceed with auth flow
186 |         this.authCompletedSuccessfully = false;
187 |         await this.stop(); // Stop the server we just started
188 |         return false;
189 |     }
190 | 
191 |     // Generate Auth URL using the newly created flow client
192 |     const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({
193 |       access_type: 'offline',
194 |       scope: ['https://www.googleapis.com/auth/calendar'],
195 |       prompt: 'consent'
196 |     });
197 |     
198 |     // Always show the URL in console for easy access
199 |     process.stderr.write(`\n🔗 Authentication URL: ${authorizeUrl}\n\n`);
200 |     process.stderr.write(`Or visit: http://localhost:${port}\n\n`);
201 |     
202 |     if (openBrowser) {
203 |       try {
204 |         await open(authorizeUrl);
205 |         process.stderr.write(`Browser opened automatically. If it didn't open, use the URL above.\n`);
206 |       } catch (error) {
207 |         process.stderr.write(`Could not open browser automatically. Please use the URL above.\n`);
208 |       }
209 |     } else {
210 |       process.stderr.write(`Please visit the URL above to complete authentication.\n`);
211 |     }
212 | 
213 |     return true; // Auth flow initiated
214 |   }
215 | 
216 |   private async startServerOnAvailablePort(): Promise<number | null> {
217 |     for (let port = this.portRange.start; port <= this.portRange.end; port++) {
218 |       try {
219 |         await new Promise<void>((resolve, reject) => {
220 |           const testServer = this.createServer();
221 |           testServer.listen(port, () => {
222 |             this.server = testServer; // Assign to class property *only* if successful
223 |             resolve();
224 |           });
225 |           testServer.on('error', (err: NodeJS.ErrnoException) => {
226 |             if (err.code === 'EADDRINUSE') {
227 |               // Port is in use, close the test server and reject
228 |               testServer.close(() => reject(err)); 
229 |             } else {
230 |               // Other error, reject
231 |               reject(err);
232 |             }
233 |           });
234 |         });
235 |         return port; // Port successfully bound
236 |       } catch (error: unknown) {
237 |         // Check if it's EADDRINUSE, otherwise rethrow or handle
238 |         if (!(error instanceof Error && 'code' in error && error.code === 'EADDRINUSE')) {
239 |             // An unexpected error occurred during server start
240 |             return null;
241 |         }
242 |         // EADDRINUSE occurred, loop continues
243 |       }
244 |     }
245 |     return null; // No port found
246 |   }
247 | 
248 |   public getRunningPort(): number | null {
249 |     if (this.server) {
250 |       const address = this.server.address();
251 |       if (typeof address === 'object' && address !== null) {
252 |         return address.port;
253 |       }
254 |     }
255 |     return null;
256 |   }
257 | 
258 |   async stop(): Promise<void> {
259 |     return new Promise((resolve, reject) => {
260 |       if (this.server) {
261 |         // Force close all active connections
262 |         for (const connection of this.activeConnections) {
263 |           connection.destroy();
264 |         }
265 |         this.activeConnections.clear();
266 |         
267 |         // Add a timeout to force close if server doesn't close gracefully
268 |         const timeout = setTimeout(() => {
269 |           process.stderr.write('Server close timeout, forcing exit...\n');
270 |           this.server = null;
271 |           resolve();
272 |         }, 2000); // 2 second timeout
273 |         
274 |         this.server.close((err) => {
275 |           clearTimeout(timeout);
276 |           if (err) {
277 |             reject(err);
278 |           } else {
279 |             this.server = null;
280 |             resolve();
281 |           }
282 |         });
283 |       } else {
284 |         resolve();
285 |       }
286 |     });
287 |   }
288 | } 
```

--------------------------------------------------------------------------------
/src/tests/unit/handlers/BatchRequestHandler.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @jest-environment node
  3 |  */
  4 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  5 | import { OAuth2Client } from 'google-auth-library';
  6 | import { BatchRequestHandler, BatchRequest, BatchResponse } from '../../../handlers/core/BatchRequestHandler.js';
  7 | 
  8 | describe('BatchRequestHandler', () => {
  9 |   let mockOAuth2Client: OAuth2Client;
 10 |   let batchHandler: BatchRequestHandler;
 11 | 
 12 |   beforeEach(() => {
 13 |     vi.clearAllMocks();
 14 |     mockOAuth2Client = {
 15 |       getAccessToken: vi.fn().mockResolvedValue({ token: 'mock_access_token' })
 16 |     } as any;
 17 |     batchHandler = new BatchRequestHandler(mockOAuth2Client);
 18 |   });
 19 | 
 20 |   describe('Batch Request Creation', () => {
 21 |     it('should create proper multipart request body with single request', () => {
 22 |       const requests: BatchRequest[] = [
 23 |         {
 24 |           method: 'GET',
 25 |           path: '/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime'
 26 |         }
 27 |       ];
 28 | 
 29 |       const result = (batchHandler as any).createBatchBody(requests);
 30 |       const boundary = (batchHandler as any).boundary;
 31 | 
 32 |       expect(result).toContain(`--${boundary}`);
 33 |       expect(result).toContain('Content-Type: application/http');
 34 |       expect(result).toContain('Content-ID: <item1>');
 35 |       expect(result).toContain('GET /calendar/v3/calendars/primary/events');
 36 |       expect(result).toContain('singleEvents=true');
 37 |       expect(result).toContain('orderBy=startTime');
 38 |       expect(result).toContain(`--${boundary}--`);
 39 |     });
 40 | 
 41 |     it('should create proper multipart request body with multiple requests', () => {
 42 |       const requests: BatchRequest[] = [
 43 |         {
 44 |           method: 'GET',
 45 |           path: '/calendar/v3/calendars/primary/events'
 46 |         },
 47 |         {
 48 |           method: 'GET',
 49 |           path: '/calendar/v3/calendars/work%40example.com/events'
 50 |         },
 51 |         {
 52 |           method: 'GET',
 53 |           path: '/calendar/v3/calendars/personal%40example.com/events'
 54 |         }
 55 |       ];
 56 | 
 57 |       const result = (batchHandler as any).createBatchBody(requests);
 58 |       const boundary = (batchHandler as any).boundary;
 59 | 
 60 |       expect(result).toContain('Content-ID: <item1>');
 61 |       expect(result).toContain('Content-ID: <item2>');
 62 |       expect(result).toContain('Content-ID: <item3>');
 63 |       expect(result).toContain('calendars/primary/events');
 64 |       expect(result).toContain('calendars/work%40example.com/events');
 65 |       expect(result).toContain('calendars/personal%40example.com/events');
 66 |       
 67 |       // Should have proper boundary structure
 68 |       const boundaryCount = (result.match(new RegExp(`--${boundary}`, 'g')) || []).length;
 69 |       expect(boundaryCount).toBe(4); // 3 request boundaries + 1 end boundary
 70 |     });
 71 | 
 72 |     it('should handle requests with custom headers', () => {
 73 |       const requests: BatchRequest[] = [
 74 |         {
 75 |           method: 'POST',
 76 |           path: '/calendar/v3/calendars/primary/events',
 77 |           headers: {
 78 |             'If-Match': '"etag123"',
 79 |             'X-Custom-Header': 'custom-value'
 80 |           }
 81 |         }
 82 |       ];
 83 | 
 84 |       const result = (batchHandler as any).createBatchBody(requests);
 85 | 
 86 |       expect(result).toContain('If-Match: "etag123"');
 87 |       expect(result).toContain('X-Custom-Header: custom-value');
 88 |     });
 89 | 
 90 |     it('should handle requests with JSON body', () => {
 91 |       const requestBody = {
 92 |         summary: 'Test Event',
 93 |         start: { dateTime: '2024-01-15T10:00:00Z' },
 94 |         end: { dateTime: '2024-01-15T11:00:00Z' }
 95 |       };
 96 | 
 97 |       const requests: BatchRequest[] = [
 98 |         {
 99 |           method: 'POST',
100 |           path: '/calendar/v3/calendars/primary/events',
101 |           body: requestBody
102 |         }
103 |       ];
104 | 
105 |       const result = (batchHandler as any).createBatchBody(requests);
106 | 
107 |       expect(result).toContain('Content-Type: application/json');
108 |       expect(result).toContain(JSON.stringify(requestBody));
109 |       expect(result).toContain('"summary":"Test Event"');
110 |     });
111 | 
112 |     it('should encode URLs properly in batch requests', () => {
113 |       const requests: BatchRequest[] = [
114 |         {
115 |           method: 'GET',
116 |           path: '/calendar/v3/calendars/test%40example.com/events?timeMin=2024-01-01T00%3A00%3A00Z'
117 |         }
118 |       ];
119 | 
120 |       const result = (batchHandler as any).createBatchBody(requests);
121 | 
122 |       expect(result).toContain('calendars/test%40example.com/events');
123 |       expect(result).toContain('timeMin=2024-01-01T00%3A00%3A00Z');
124 |     });
125 |   });
126 | 
127 |   describe('Batch Response Parsing', () => {
128 |     it('should parse successful response correctly', () => {
129 |       const mockResponseText = `HTTP/1.1 200 OK
130 | Content-Length: response_total_content_length
131 | Content-Type: multipart/mixed; boundary=batch_abc123
132 | 
133 | --batch_abc123
134 | Content-Type: application/http
135 | Content-ID: <response-item1>
136 | 
137 | HTTP/1.1 200 OK
138 | Content-Type: application/json
139 | Content-Length: 123
140 | 
141 | {
142 |   "items": [
143 |     {
144 |       "id": "event1",
145 |       "summary": "Test Event",
146 |       "start": {"dateTime": "2024-01-15T10:00:00Z"},
147 |       "end": {"dateTime": "2024-01-15T11:00:00Z"}
148 |     }
149 |   ]
150 | }
151 | 
152 | --batch_abc123--`;
153 | 
154 |       const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
155 | 
156 |       expect(responses).toHaveLength(1);
157 |       expect(responses[0].statusCode).toBe(200);
158 |       expect(responses[0].body.items).toHaveLength(1);
159 |       expect(responses[0].body.items[0].summary).toBe('Test Event');
160 |     });
161 | 
162 |     it('should parse multiple responses correctly', () => {
163 |       const mockResponseText = `HTTP/1.1 200 OK
164 | Content-Type: multipart/mixed; boundary=batch_abc123
165 | 
166 | --batch_abc123
167 | Content-Type: application/http
168 | Content-ID: <response-item1>
169 | 
170 | HTTP/1.1 200 OK
171 | Content-Type: application/json
172 | 
173 | {"items": [{"id": "event1", "summary": "Event 1"}]}
174 | 
175 | --batch_abc123
176 | Content-Type: application/http
177 | Content-ID: <response-item2>
178 | 
179 | HTTP/1.1 200 OK
180 | Content-Type: application/json
181 | 
182 | {"items": [{"id": "event2", "summary": "Event 2"}]}
183 | 
184 | --batch_abc123--`;
185 | 
186 |       const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
187 | 
188 |       expect(responses).toHaveLength(2);
189 |       expect(responses[0].body.items[0].summary).toBe('Event 1');
190 |       expect(responses[1].body.items[0].summary).toBe('Event 2');
191 |     });
192 | 
193 |     it('should handle error responses in batch', () => {
194 |       const mockResponseText = `HTTP/1.1 200 OK
195 | Content-Type: multipart/mixed; boundary=batch_abc123
196 | 
197 | --batch_abc123
198 | Content-Type: application/http
199 | Content-ID: <response-item1>
200 | 
201 | HTTP/1.1 404 Not Found
202 | Content-Type: application/json
203 | 
204 | {
205 |   "error": {
206 |     "code": 404,
207 |     "message": "Calendar not found"
208 |   }
209 | }
210 | 
211 | --batch_abc123--`;
212 | 
213 |       const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
214 | 
215 |       expect(responses).toHaveLength(1);
216 |       expect(responses[0].statusCode).toBe(404);
217 |       expect(responses[0].body.error.code).toBe(404);
218 |       expect(responses[0].body.error.message).toBe('Calendar not found');
219 |     });
220 | 
221 |     it('should handle mixed success and error responses', () => {
222 |       const mockResponseText = `HTTP/1.1 200 OK
223 | Content-Type: multipart/mixed; boundary=batch_abc123
224 | 
225 | --batch_abc123
226 | Content-Type: application/http
227 | Content-ID: <response-item1>
228 | 
229 | HTTP/1.1 200 OK
230 | Content-Type: application/json
231 | 
232 | {"items": [{"id": "event1", "summary": "Success"}]}
233 | 
234 | --batch_abc123
235 | Content-Type: application/http
236 | Content-ID: <response-item2>
237 | 
238 | HTTP/1.1 403 Forbidden
239 | Content-Type: application/json
240 | 
241 | {
242 |   "error": {
243 |     "code": 403,
244 |     "message": "Access denied"
245 |   }
246 | }
247 | 
248 | --batch_abc123--`;
249 | 
250 |       const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
251 | 
252 |       expect(responses).toHaveLength(2);
253 |       expect(responses[0].statusCode).toBe(200);
254 |       expect(responses[0].body.items[0].summary).toBe('Success');
255 |       expect(responses[1].statusCode).toBe(403);
256 |       expect(responses[1].body.error.message).toBe('Access denied');
257 |     });
258 | 
259 |     it('should handle empty response parts gracefully', () => {
260 |       const mockResponseText = `HTTP/1.1 200 OK
261 | Content-Type: multipart/mixed; boundary=batch_abc123
262 | 
263 | --batch_abc123
264 | 
265 | 
266 | --batch_abc123
267 | Content-Type: application/http
268 | Content-ID: <response-item1>
269 | 
270 | HTTP/1.1 200 OK
271 | Content-Type: application/json
272 | 
273 | {"items": []}
274 | 
275 | --batch_abc123--`;
276 | 
277 |       const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
278 | 
279 |       expect(responses).toHaveLength(1);
280 |       expect(responses[0].statusCode).toBe(200);
281 |       expect(responses[0].body.items).toEqual([]);
282 |     });
283 | 
284 |     it('should handle malformed JSON gracefully', () => {
285 |       const mockResponseText = `HTTP/1.1 200 OK
286 | Content-Type: multipart/mixed; boundary=batch_abc123
287 | 
288 | --batch_abc123
289 | Content-Type: application/http
290 | Content-ID: <response-item1>
291 | 
292 | HTTP/1.1 200 OK
293 | Content-Type: application/json
294 | 
295 | {invalid json here}
296 | 
297 | --batch_abc123--`;
298 | 
299 |       const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
300 | 
301 |       expect(responses).toHaveLength(1);
302 |       expect(responses[0].statusCode).toBe(200);
303 |       expect(responses[0].body).toBe('{invalid json here}');
304 |     });
305 |   });
306 | 
307 |   describe('Integration Tests', () => {
308 |     it('should execute batch request with mocked fetch', async () => {
309 |       const mockResponseText = `HTTP/1.1 200 OK
310 | Content-Type: multipart/mixed; boundary=batch_abc123
311 | 
312 | --batch_abc123
313 | Content-Type: application/http
314 | 
315 | HTTP/1.1 200 OK
316 | Content-Type: application/json
317 | 
318 | {"items": [{"id": "event1", "summary": "Test"}]}
319 | 
320 | --batch_abc123--`;
321 | 
322 |       global.fetch = vi.fn().mockResolvedValue({
323 |         ok: true,
324 |         status: 200,
325 |         statusText: 'OK',
326 |         text: () => Promise.resolve(mockResponseText)
327 |       });
328 | 
329 |       const requests: BatchRequest[] = [
330 |         {
331 |           method: 'GET',
332 |           path: '/calendar/v3/calendars/primary/events'
333 |         }
334 |       ];
335 | 
336 |       const responses = await batchHandler.executeBatch(requests);
337 | 
338 |       expect(global.fetch).toHaveBeenCalledWith(
339 |         'https://www.googleapis.com/batch/calendar/v3',
340 |         expect.objectContaining({
341 |           method: 'POST',
342 |           headers: expect.objectContaining({
343 |             'Authorization': 'Bearer mock_access_token',
344 |             'Content-Type': expect.stringContaining('multipart/mixed; boundary=')
345 |           })
346 |         })
347 |       );
348 | 
349 |       expect(responses).toHaveLength(1);
350 |       expect(responses[0].statusCode).toBe(200);
351 |     });
352 | 
353 |     it('should handle network errors during batch execution', async () => {
354 |       // Create a handler with no retries for this test
355 |       const noRetryHandler = new BatchRequestHandler(mockOAuth2Client);
356 |       (noRetryHandler as any).maxRetries = 0; // Override max retries
357 |       
358 |       global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
359 | 
360 |       const requests: BatchRequest[] = [
361 |         {
362 |           method: 'GET',
363 |           path: '/calendar/v3/calendars/primary/events'
364 |         }
365 |       ];
366 | 
367 |       await expect(noRetryHandler.executeBatch(requests))
368 |         .rejects.toThrow('Failed to execute batch request: Network error');
369 |     });
370 | 
371 |     it('should handle authentication errors', async () => {
372 |       mockOAuth2Client.getAccessToken = vi.fn().mockRejectedValue(
373 |         new Error('Authentication failed')
374 |       );
375 | 
376 |       const requests: BatchRequest[] = [
377 |         {
378 |           method: 'GET',
379 |           path: '/calendar/v3/calendars/primary/events'
380 |         }
381 |       ];
382 | 
383 |       await expect(batchHandler.executeBatch(requests))
384 |         .rejects.toThrow('Authentication failed');
385 |     });
386 |   });
387 | }); 
```

--------------------------------------------------------------------------------
/src/tests/unit/handlers/CalendarNameResolution.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Unit tests for calendar name resolution feature
  3 |  * Tests the resolveCalendarId and resolveCalendarIds methods in BaseToolHandler
  4 |  */
  5 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  6 | import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
  7 | import { OAuth2Client } from 'google-auth-library';
  8 | import { google } from 'googleapis';
  9 | 
 10 | // Mock googleapis globally
 11 | vi.mock('googleapis', () => ({
 12 |   google: {
 13 |     calendar: vi.fn(() => ({
 14 |       events: {
 15 |         list: vi.fn()
 16 |       },
 17 |       calendarList: {
 18 |         list: vi.fn(),
 19 |         get: vi.fn()
 20 |       }
 21 |     }))
 22 |   }
 23 | }));
 24 | 
 25 | describe('Calendar Name Resolution', () => {
 26 |   const mockOAuth2Client = {
 27 |     getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
 28 |   } as unknown as OAuth2Client;
 29 | 
 30 |   let handler: ListEventsHandler;
 31 |   let mockCalendar: any;
 32 | 
 33 |   beforeEach(() => {
 34 |     handler = new ListEventsHandler();
 35 |     mockCalendar = {
 36 |       events: {
 37 |         list: vi.fn().mockResolvedValue({
 38 |           data: {
 39 |             items: []
 40 |           }
 41 |         })
 42 |       },
 43 |       calendarList: {
 44 |         list: vi.fn().mockResolvedValue({
 45 |           data: {
 46 |             items: [
 47 |               {
 48 |                 id: 'primary',
 49 |                 summary: 'Primary Calendar',
 50 |                 summaryOverride: undefined
 51 |               },
 52 |               {
 53 |                 id: '[email protected]',
 54 |                 summary: 'Engineering Team - Project Alpha - Q4 2024',
 55 |                 summaryOverride: 'Work Calendar'
 56 |               },
 57 |               {
 58 |                 id: '[email protected]',
 59 |                 summary: 'Personal Calendar',
 60 |                 summaryOverride: undefined
 61 |               },
 62 |               {
 63 |                 id: '[email protected]',
 64 |                 summary: 'Team Events',
 65 |                 summaryOverride: 'My Team'
 66 |               }
 67 |             ]
 68 |           }
 69 |         }),
 70 |         get: vi.fn().mockResolvedValue({
 71 |           data: { timeZone: 'UTC' }
 72 |         })
 73 |       }
 74 |     };
 75 |     vi.mocked(google.calendar).mockReturnValue(mockCalendar);
 76 |   });
 77 | 
 78 |   describe('summaryOverride matching priority', () => {
 79 |     it('should match summaryOverride before summary (exact match)', async () => {
 80 |       const args = {
 81 |         calendarId: 'Work Calendar',
 82 |         timeMin: '2025-06-02T00:00:00Z',
 83 |         timeMax: '2025-06-09T23:59:59Z'
 84 |       };
 85 | 
 86 |       await handler.runTool(args, mockOAuth2Client);
 87 | 
 88 |       // Should have called events.list with the resolved ID
 89 |       expect(mockCalendar.events.list).toHaveBeenCalledWith(
 90 |         expect.objectContaining({
 91 |           calendarId: '[email protected]'
 92 |         })
 93 |       );
 94 |     });
 95 | 
 96 |     it('should fall back to summary if summaryOverride does not match', async () => {
 97 |       const args = {
 98 |         calendarId: 'Personal Calendar',
 99 |         timeMin: '2025-06-02T00:00:00Z',
100 |         timeMax: '2025-06-09T23:59:59Z'
101 |       };
102 | 
103 |       await handler.runTool(args, mockOAuth2Client);
104 | 
105 |       expect(mockCalendar.events.list).toHaveBeenCalledWith(
106 |         expect.objectContaining({
107 |           calendarId: '[email protected]'
108 |         })
109 |       );
110 |     });
111 | 
112 |     it('should match summaryOverride case-insensitively', async () => {
113 |       const args = {
114 |         calendarId: 'WORK CALENDAR',
115 |         timeMin: '2025-06-02T00:00:00Z',
116 |         timeMax: '2025-06-09T23:59:59Z'
117 |       };
118 | 
119 |       await handler.runTool(args, mockOAuth2Client);
120 | 
121 |       expect(mockCalendar.events.list).toHaveBeenCalledWith(
122 |         expect.objectContaining({
123 |           calendarId: '[email protected]'
124 |         })
125 |       );
126 |     });
127 | 
128 |     it('should match summary case-insensitively', async () => {
129 |       const args = {
130 |         calendarId: 'personal calendar',
131 |         timeMin: '2025-06-02T00:00:00Z',
132 |         timeMax: '2025-06-09T23:59:59Z'
133 |       };
134 | 
135 |       await handler.runTool(args, mockOAuth2Client);
136 | 
137 |       expect(mockCalendar.events.list).toHaveBeenCalledWith(
138 |         expect.objectContaining({
139 |           calendarId: '[email protected]'
140 |         })
141 |       );
142 |     });
143 | 
144 |     it('should prefer summaryOverride over similar summary name', async () => {
145 |       // Even if there's a calendar with summary "My Team",
146 |       // it should match the summaryOverride first
147 |       const args = {
148 |         calendarId: 'My Team',
149 |         timeMin: '2025-06-02T00:00:00Z',
150 |         timeMax: '2025-06-09T23:59:59Z'
151 |       };
152 | 
153 |       await handler.runTool(args, mockOAuth2Client);
154 | 
155 |       expect(mockCalendar.events.list).toHaveBeenCalledWith(
156 |         expect.objectContaining({
157 |           calendarId: '[email protected]'
158 |         })
159 |       );
160 |     });
161 |   });
162 | 
163 |   describe('multiple calendar name resolution', () => {
164 |     it('should resolve multiple calendar names including summaryOverride', async () => {
165 |       const args = {
166 |         calendarId: ['Work Calendar', 'Personal Calendar'],  // Pass as array, not JSON string
167 |         timeMin: '2025-06-02T00:00:00Z',
168 |         timeMax: '2025-06-09T23:59:59Z'
169 |       };
170 | 
171 |       // Mock fetch for batch requests
172 |       global.fetch = vi.fn().mockResolvedValue({
173 |         ok: true,
174 |         status: 200,
175 |         headers: {
176 |           get: vi.fn()
177 |         },
178 |         text: () => Promise.resolve(`--batch_boundary
179 | Content-Type: application/http
180 | Content-ID: <item1>
181 | 
182 | HTTP/1.1 200 OK
183 | Content-Type: application/json
184 | 
185 | {"items": []}
186 | 
187 | --batch_boundary
188 | Content-Type: application/http
189 | Content-ID: <item2>
190 | 
191 | HTTP/1.1 200 OK
192 | Content-Type: application/json
193 | 
194 | {"items": []}
195 | 
196 | --batch_boundary--`)
197 |       });
198 | 
199 |       await handler.runTool(args, mockOAuth2Client);
200 | 
201 |       // Should have called fetch with both resolved calendar IDs
202 |       expect(global.fetch).toHaveBeenCalled();
203 |       const fetchCall = vi.mocked(global.fetch).mock.calls[0];
204 |       const requestBody = fetchCall[1]?.body as string;
205 | 
206 |       // Calendar IDs may be URL-encoded in batch request
207 |       expect(requestBody).toMatch(/work@example\.com|work%40example\.com/);
208 |       expect(requestBody).toMatch(/personal@example\.com|personal%40example\.com/);
209 |     });
210 | 
211 |     it('should resolve mix of IDs, summary names, and summaryOverride names', async () => {
212 |       const args = {
213 |         calendarId: ['primary', 'Work Calendar', 'Personal Calendar'],  // Pass as array
214 |         timeMin: '2025-06-02T00:00:00Z',
215 |         timeMax: '2025-06-09T23:59:59Z'
216 |       };
217 | 
218 |       global.fetch = vi.fn().mockResolvedValue({
219 |         ok: true,
220 |         status: 200,
221 |         headers: {
222 |           get: vi.fn()
223 |         },
224 |         text: () => Promise.resolve(`--batch_boundary
225 | Content-Type: application/http
226 | 
227 | HTTP/1.1 200 OK
228 | 
229 | {"items": []}
230 | --batch_boundary--`)
231 |       });
232 | 
233 |       await handler.runTool(args, mockOAuth2Client);
234 | 
235 |       const fetchCall = vi.mocked(global.fetch).mock.calls[0];
236 |       const requestBody = fetchCall[1]?.body as string;
237 | 
238 |       // Should include all three calendar IDs (may be URL-encoded)
239 |       expect(requestBody).toContain('primary');
240 |       expect(requestBody).toMatch(/work@example\.com|work%40example\.com/);
241 |       expect(requestBody).toMatch(/personal@example\.com|personal%40example\.com/);
242 |     });
243 |   });
244 | 
245 |   describe('error handling with summaryOverride', () => {
246 |     it('should provide helpful error listing both summaryOverride and summary', async () => {
247 |       const args = {
248 |         calendarId: 'NonExistentCalendar',
249 |         timeMin: '2025-06-02T00:00:00Z',
250 |         timeMax: '2025-06-09T23:59:59Z'
251 |       };
252 | 
253 |       await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
254 |         /Calendar\(s\) not found: "NonExistentCalendar"/
255 |       );
256 | 
257 |       try {
258 |         await handler.runTool(args, mockOAuth2Client);
259 |       } catch (error: any) {
260 |         // Error message should show both override and original name
261 |         expect(error.message).toContain('Work Calendar');
262 |         expect(error.message).toContain('Engineering Team - Project Alpha - Q4 2024');
263 |         expect(error.message).toContain('My Team');
264 |         expect(error.message).toContain('Team Events');
265 |       }
266 |     });
267 | 
268 |     it('should handle calendar with summaryOverride same as summary', async () => {
269 |       // Update mock to have a calendar where override equals summary
270 |       mockCalendar.calendarList.list.mockResolvedValueOnce({
271 |         data: {
272 |           items: [
273 |             {
274 |               id: '[email protected]',
275 |               summary: 'Test Calendar',
276 |               summaryOverride: 'Test Calendar'
277 |             }
278 |           ]
279 |         }
280 |       });
281 | 
282 |       const args = {
283 |         calendarId: 'NonExistent',
284 |         timeMin: '2025-06-02T00:00:00Z',
285 |         timeMax: '2025-06-09T23:59:59Z'
286 |       };
287 | 
288 |       try {
289 |         await handler.runTool(args, mockOAuth2Client);
290 |       } catch (error: any) {
291 |         // Should not show duplicate when override equals summary
292 |         const message = error.message;
293 |         const matches = (message.match(/Test Calendar/g) || []).length;
294 |         expect(matches).toBe(1);
295 |       }
296 |     });
297 |   });
298 | 
299 |   describe('performance optimization', () => {
300 |     it('should skip API call when all inputs are IDs', async () => {
301 |       const args = {
302 |         calendarId: ['primary', '[email protected]'],  // Pass as array
303 |         timeMin: '2025-06-02T00:00:00Z',
304 |         timeMax: '2025-06-09T23:59:59Z'
305 |       };
306 | 
307 |       // Reset the mock to track calls
308 |       mockCalendar.calendarList.list.mockClear();
309 | 
310 |       global.fetch = vi.fn().mockResolvedValue({
311 |         ok: true,
312 |         status: 200,
313 |         headers: {
314 |           get: vi.fn()
315 |         },
316 |         text: () => Promise.resolve(`--batch_boundary
317 | Content-Type: application/http
318 | 
319 | HTTP/1.1 200 OK
320 | 
321 | {"items": []}
322 | --batch_boundary--`)
323 |       });
324 | 
325 |       await handler.runTool(args, mockOAuth2Client);
326 | 
327 |       // Should NOT have called calendarList.list since all inputs are IDs
328 |       expect(mockCalendar.calendarList.list).not.toHaveBeenCalled();
329 |     });
330 | 
331 |     it('should call API only once for multiple name resolutions', async () => {
332 |       const args = {
333 |         calendarId: ['Work Calendar', 'Personal Calendar', 'My Team'],  // Pass as array
334 |         timeMin: '2025-06-02T00:00:00Z',
335 |         timeMax: '2025-06-09T23:59:59Z'
336 |       };
337 | 
338 |       mockCalendar.calendarList.list.mockClear();
339 | 
340 |       global.fetch = vi.fn().mockResolvedValue({
341 |         ok: true,
342 |         status: 200,
343 |         headers: {
344 |           get: vi.fn()
345 |         },
346 |         text: () => Promise.resolve(`--batch_boundary
347 | Content-Type: application/http
348 | 
349 | HTTP/1.1 200 OK
350 | 
351 | {"items": []}
352 | --batch_boundary--`)
353 |       });
354 | 
355 |       await handler.runTool(args, mockOAuth2Client);
356 | 
357 |       // Should have called calendarList.list exactly once
358 |       expect(mockCalendar.calendarList.list).toHaveBeenCalledTimes(1);
359 |     });
360 |   });
361 | 
362 |   describe('input validation', () => {
363 |     it('should filter out empty strings', async () => {
364 |       const args = {
365 |         calendarId: ['primary', '', 'Work Calendar'],  // Pass as array
366 |         timeMin: '2025-06-02T00:00:00Z',
367 |         timeMax: '2025-06-09T23:59:59Z'
368 |       };
369 | 
370 |       global.fetch = vi.fn().mockResolvedValue({
371 |         ok: true,
372 |         status: 200,
373 |         headers: {
374 |           get: vi.fn()
375 |         },
376 |         text: () => Promise.resolve(`--batch_boundary
377 | Content-Type: application/http
378 | 
379 | HTTP/1.1 200 OK
380 | 
381 | {"items": []}
382 | --batch_boundary--`)
383 |       });
384 | 
385 |       // Should not throw - empty string should be filtered out
386 |       await expect(handler.runTool(args, mockOAuth2Client)).resolves.toBeDefined();
387 |     });
388 | 
389 |     it('should reject when all inputs are empty/whitespace', async () => {
390 |       const args = {
391 |         calendarId: ['', '  ', '\t'],  // Pass as array
392 |         timeMin: '2025-06-02T00:00:00Z',
393 |         timeMax: '2025-06-09T23:59:59Z'
394 |       };
395 | 
396 |       await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
397 |         /At least one valid calendar identifier is required/
398 |       );
399 |     });
400 |   });
401 | });
402 | 
```

--------------------------------------------------------------------------------
/src/tests/unit/index.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for the Google Calendar MCP Server implementation
  3 |  */
  4 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  6 | import { OAuth2Client } from "google-auth-library";
  7 | 
  8 | // Import tool handlers to test them directly
  9 | import { ListCalendarsHandler } from "../../handlers/core/ListCalendarsHandler.js";
 10 | import { CreateEventHandler } from "../../handlers/core/CreateEventHandler.js";
 11 | import { ListEventsHandler } from "../../handlers/core/ListEventsHandler.js";
 12 | 
 13 | // Mock OAuth2Client
 14 | vi.mock('google-auth-library', () => ({
 15 |   OAuth2Client: vi.fn().mockImplementation(() => ({
 16 |     setCredentials: vi.fn(),
 17 |     refreshAccessToken: vi.fn().mockResolvedValue({ credentials: { access_token: 'mock_access_token' } }),
 18 |     on: vi.fn(),
 19 |   }))
 20 | }));
 21 | 
 22 | // Mock googleapis
 23 | vi.mock('googleapis', () => ({
 24 |   google: {
 25 |     calendar: vi.fn().mockReturnValue({
 26 |       calendarList: {
 27 |         list: vi.fn(),
 28 |         get: vi.fn()
 29 |       },
 30 |       events: {
 31 |         list: vi.fn(),
 32 |         insert: vi.fn(),
 33 |         patch: vi.fn(),
 34 |         delete: vi.fn()
 35 |       },
 36 |       colors: {
 37 |         get: vi.fn()
 38 |       },
 39 |       freebusy: {
 40 |         query: vi.fn()
 41 |       }
 42 |     })
 43 |   }
 44 | }));
 45 | 
 46 | // Mock TokenManager
 47 | vi.mock('./auth/tokenManager.js', () => ({
 48 |   TokenManager: vi.fn().mockImplementation(() => ({
 49 |     validateTokens: vi.fn().mockResolvedValue(true),
 50 |     loadSavedTokens: vi.fn().mockResolvedValue(true),
 51 |     clearTokens: vi.fn(),
 52 |   })),
 53 | }));
 54 | 
 55 | describe('Google Calendar MCP Server', () => {
 56 |   let mockOAuth2Client: OAuth2Client;
 57 | 
 58 |   beforeEach(() => {
 59 |     vi.clearAllMocks();
 60 |     mockOAuth2Client = new OAuth2Client();
 61 |   });
 62 | 
 63 |   describe('McpServer Configuration', () => {
 64 |     it('should create McpServer with correct configuration', () => {
 65 |       const server = new McpServer({
 66 |         name: "google-calendar",
 67 |         version: "1.2.0"
 68 |       });
 69 | 
 70 |       expect(server).toBeDefined();
 71 |       // McpServer doesn't expose internal configuration for testing,
 72 |       // but we can verify it doesn't throw during creation
 73 |     });
 74 |   });
 75 | 
 76 |   describe('Tool Handlers', () => {
 77 |     it('should handle list-calendars tool correctly', async () => {
 78 |       const handler = new ListCalendarsHandler();
 79 |       const { google } = await import('googleapis');
 80 |       const mockCalendarApi = google.calendar('v3');
 81 | 
 82 |       // Mock the API response
 83 |       (mockCalendarApi.calendarList.list as any).mockResolvedValue({
 84 |         data: {
 85 |           items: [
 86 |             { 
 87 |               id: 'cal1', 
 88 |               summary: 'Work Calendar',
 89 |               timeZone: 'America/New_York',
 90 |               kind: 'calendar#calendarListEntry',
 91 |               accessRole: 'owner',
 92 |               primary: true,
 93 |               selected: true,
 94 |               hidden: false,
 95 |               backgroundColor: '#0D7377',
 96 |               defaultReminders: [
 97 |                 { method: 'popup', minutes: 15 },
 98 |                 { method: 'email', minutes: 60 }
 99 |               ],
100 |               description: 'Work-related events and meetings'
101 |             },
102 |             { 
103 |               id: 'cal2', 
104 |               summary: 'Personal',
105 |               timeZone: 'America/Los_Angeles',
106 |               kind: 'calendar#calendarListEntry',
107 |               accessRole: 'reader',
108 |               primary: false,
109 |               selected: true,
110 |               hidden: false,
111 |               backgroundColor: '#D50000'
112 |             },
113 |           ]
114 |         }
115 |       });
116 | 
117 |       const result = await handler.runTool({}, mockOAuth2Client);
118 | 
119 |       expect(mockCalendarApi.calendarList.list).toHaveBeenCalled();
120 | 
121 |       // Parse the JSON response
122 |       const response = JSON.parse((result.content as any)[0].text);
123 | 
124 |       expect(response.totalCount).toBe(2);
125 |       expect(response.calendars).toHaveLength(2);
126 |       expect(response.calendars[0]).toMatchObject({
127 |         id: 'cal1',
128 |         summary: 'Work Calendar',
129 |         description: 'Work-related events and meetings',
130 |         timeZone: 'America/New_York',
131 |         backgroundColor: '#0D7377',
132 |         accessRole: 'owner',
133 |         primary: true,
134 |         selected: true,
135 |         hidden: false
136 |       });
137 |       expect(response.calendars[0].defaultReminders).toHaveLength(2);
138 |       expect(response.calendars[1]).toMatchObject({
139 |         id: 'cal2',
140 |         summary: 'Personal',
141 |         timeZone: 'America/Los_Angeles',
142 |         backgroundColor: '#D50000',
143 |         accessRole: 'reader',
144 |         primary: false
145 |       });
146 |     });
147 | 
148 |     it('should handle create-event tool with valid arguments', async () => {
149 |       const handler = new CreateEventHandler();
150 |       const { google } = await import('googleapis');
151 |       const mockCalendarApi = google.calendar('v3');
152 | 
153 |       const mockEventArgs = {
154 |         calendarId: 'primary',
155 |         summary: 'Team Meeting',
156 |         description: 'Discuss project progress',
157 |         start: '2024-08-15T10:00:00',
158 |         end: '2024-08-15T11:00:00',
159 |         attendees: [{ email: '[email protected]' }],
160 |         location: 'Conference Room 4',
161 |       };
162 | 
163 |       const mockApiResponse = {
164 |         id: 'eventId123',
165 |         summary: mockEventArgs.summary,
166 |       };
167 | 
168 |       // Mock calendar details for timezone retrieval
169 |       (mockCalendarApi.calendarList.get as any).mockResolvedValue({
170 |         data: {
171 |           id: 'primary',
172 |           timeZone: 'America/Los_Angeles'
173 |         }
174 |       });
175 | 
176 |       (mockCalendarApi.events.insert as any).mockResolvedValue({ data: mockApiResponse });
177 | 
178 |       const result = await handler.runTool(mockEventArgs, mockOAuth2Client);
179 | 
180 |       expect(mockCalendarApi.calendarList.get).toHaveBeenCalledWith({ calendarId: 'primary' });
181 |       expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
182 |         calendarId: mockEventArgs.calendarId,
183 |         requestBody: expect.objectContaining({
184 |           summary: mockEventArgs.summary,
185 |           description: mockEventArgs.description,
186 |           start: { dateTime: mockEventArgs.start, timeZone: 'America/Los_Angeles' },
187 |           end: { dateTime: mockEventArgs.end, timeZone: 'America/Los_Angeles' },
188 |           attendees: mockEventArgs.attendees,
189 |           location: mockEventArgs.location,
190 |         }),
191 |       });
192 | 
193 |       expect(result.content).toHaveLength(1);
194 |       expect(result.content[0].type).toBe('text');
195 |       const response = JSON.parse((result.content[0] as any).text);
196 |       expect(response.event).toBeDefined();
197 |       expect(response.event.id).toBe('eventId123');
198 |       expect(response.event.summary).toBe('Team Meeting');
199 |     });
200 | 
201 |     it('should use calendar default timezone when timeZone is not provided', async () => {
202 |       const handler = new CreateEventHandler();
203 |       const { google } = await import('googleapis');
204 |       const mockCalendarApi = google.calendar('v3');
205 | 
206 |       const mockEventArgs = {
207 |         calendarId: 'primary',
208 |         summary: 'Meeting without timezone',
209 |         start: '2024-08-15T10:00:00', // Timezone-naive datetime
210 |         end: '2024-08-15T11:00:00', // Timezone-naive datetime
211 |       };
212 | 
213 |       // Mock calendar details with specific timezone
214 |       (mockCalendarApi.calendarList.get as any).mockResolvedValue({
215 |         data: {
216 |           id: 'primary',
217 |           timeZone: 'Europe/London'
218 |         }
219 |       });
220 | 
221 |       (mockCalendarApi.events.insert as any).mockResolvedValue({
222 |         data: { id: 'testEvent', summary: mockEventArgs.summary }
223 |       });
224 | 
225 |       await handler.runTool(mockEventArgs, mockOAuth2Client);
226 | 
227 |       // Verify that the calendar's timezone was used
228 |       expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
229 |         calendarId: mockEventArgs.calendarId,
230 |         requestBody: expect.objectContaining({
231 |           start: { dateTime: mockEventArgs.start, timeZone: 'Europe/London' },
232 |           end: { dateTime: mockEventArgs.end, timeZone: 'Europe/London' },
233 |         }),
234 |       });
235 |     });
236 | 
237 |     it('should handle timezone-aware datetime strings correctly', async () => {
238 |       const handler = new CreateEventHandler();
239 |       const { google } = await import('googleapis');
240 |       const mockCalendarApi = google.calendar('v3');
241 | 
242 |       const mockEventArgs = {
243 |         calendarId: 'primary',
244 |         summary: 'Meeting with timezone in datetime',
245 |         start: '2024-08-15T10:00:00-07:00', // Timezone-aware datetime
246 |         end: '2024-08-15T11:00:00-07:00', // Timezone-aware datetime
247 |       };
248 | 
249 |       // Mock calendar details (should not be used since timezone is in datetime)
250 |       (mockCalendarApi.calendarList.get as any).mockResolvedValue({
251 |         data: {
252 |           id: 'primary',
253 |           timeZone: 'Europe/London'
254 |         }
255 |       });
256 | 
257 |       (mockCalendarApi.events.insert as any).mockResolvedValue({
258 |         data: { id: 'testEvent', summary: mockEventArgs.summary }
259 |       });
260 | 
261 |       await handler.runTool(mockEventArgs, mockOAuth2Client);
262 | 
263 |       // Verify that timezone from datetime was used (no timeZone property)
264 |       expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
265 |         calendarId: mockEventArgs.calendarId,
266 |         requestBody: expect.objectContaining({
267 |           start: { dateTime: mockEventArgs.start }, // No timeZone property
268 |           end: { dateTime: mockEventArgs.end }, // No timeZone property
269 |         }),
270 |       });
271 |     });
272 | 
273 |     it('should handle list-events tool correctly', async () => {
274 |       const handler = new ListEventsHandler();
275 |       const { google } = await import('googleapis');
276 |       const mockCalendarApi = google.calendar('v3');
277 | 
278 |       const listEventsArgs = {
279 |         calendarId: 'primary',
280 |         timeMin: '2024-08-01T00:00:00Z',
281 |         timeMax: '2024-08-31T23:59:59Z',
282 |       };
283 | 
284 |       const mockEvents = [
285 |         { 
286 |           id: 'event1', 
287 |           summary: 'Meeting', 
288 |           start: { dateTime: '2024-08-15T10:00:00Z' }, 
289 |           end: { dateTime: '2024-08-15T11:00:00Z' } 
290 |         },
291 |       ];
292 | 
293 |       (mockCalendarApi.events.list as any).mockResolvedValue({
294 |         data: { items: mockEvents }
295 |       });
296 | 
297 |       const result = await handler.runTool(listEventsArgs, mockOAuth2Client);
298 | 
299 |       expect(mockCalendarApi.events.list).toHaveBeenCalledWith({
300 |         calendarId: listEventsArgs.calendarId,
301 |         timeMin: listEventsArgs.timeMin,
302 |         timeMax: listEventsArgs.timeMax,
303 |         singleEvents: true,
304 |         orderBy: 'startTime'
305 |       });
306 | 
307 |       // Should return structured JSON with events
308 |       expect(result.content).toHaveLength(1);
309 |       expect(result.content[0].type).toBe('text');
310 |       const response = JSON.parse((result.content[0] as any).text);
311 |       expect(response.events).toHaveLength(1);
312 |       expect(response.totalCount).toBe(1);
313 |       expect(response.events[0].id).toBe('event1');
314 |     });
315 |   });
316 | 
317 |   describe('Configuration and Environment Variables', () => {
318 |     it('should parse environment variables correctly', async () => {
319 |       const originalEnv = process.env;
320 |       
321 |       try {
322 |         // Set test environment variables
323 |         process.env.TRANSPORT = 'http';
324 |         process.env.PORT = '4000';
325 |         process.env.HOST = '0.0.0.0';
326 |         process.env.DEBUG = 'true';
327 | 
328 |         // Import config parser after setting env vars
329 |         const { parseArgs } = await import('../../config/TransportConfig.js');
330 |         
331 |         const config = parseArgs([]);
332 | 
333 |         expect(config.transport.type).toBe('http');
334 |         expect(config.transport.port).toBe(4000);
335 |         expect(config.transport.host).toBe('0.0.0.0');
336 |         expect(config.debug).toBe(true);
337 |       } finally {
338 |         // Restore original environment
339 |         process.env = originalEnv;
340 |       }
341 |     });
342 | 
343 |     it('should allow CLI arguments to override environment variables', async () => {
344 |       const originalEnv = process.env;
345 |       
346 |       try {
347 |         // Set environment variables
348 |         process.env.TRANSPORT = 'http';
349 |         process.env.PORT = '4000';
350 | 
351 |         const { parseArgs } = await import('../../config/TransportConfig.js');
352 |         
353 |         // CLI arguments should override env vars
354 |         const config = parseArgs(['--transport', 'stdio', '--port', '5000']);
355 | 
356 |         expect(config.transport.type).toBe('stdio');
357 |         expect(config.transport.port).toBe(5000);
358 |       } finally {
359 |         process.env = originalEnv;
360 |       }
361 |     });
362 |   });
363 | });
```

--------------------------------------------------------------------------------
/src/tests/unit/handlers/list-events-registry.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Comprehensive tests for list-events tool registration flow
  3 |  * Tests the complete path: schema validation → handlerFunction → handler execution
  4 |  *
  5 |  * These tests verify the fix for issue #95 by testing:
  6 |  * 1. Schema validation (accepts all formats)
  7 |  * 2. HandlerFunction preprocessing (converts single-quoted JSON, validates arrays)
  8 |  * 3. Real-world scenarios from Home Assistant and other integrations
  9 |  */
 10 | 
 11 | import { describe, it, expect } from 'vitest';
 12 | import { ToolSchemas, ToolRegistry } from '../../../tools/registry.js';
 13 | 
 14 | // Get the handlerFunction for testing the full flow
 15 | const toolDefinition = (ToolRegistry as any).tools?.find((t: any) => t.name === 'list-events');
 16 | const handlerFunction = toolDefinition?.handlerFunction;
 17 | 
 18 | describe('list-events Registration Flow (Schema + HandlerFunction)', () => {
 19 |   describe('Schema validation (first step)', () => {
 20 |     it('should validate native array format', () => {
 21 |       const input = {
 22 |         calendarId: ['primary', '[email protected]'],
 23 |         timeMin: '2024-01-01T00:00:00',
 24 |         timeMax: '2024-01-02T00:00:00'
 25 |       };
 26 | 
 27 |       const result = ToolSchemas['list-events'].safeParse(input);
 28 |       expect(result.success).toBe(true);
 29 |       expect(result.data?.calendarId).toEqual(['primary', '[email protected]']);
 30 |     });
 31 | 
 32 |     it('should validate single string format', () => {
 33 |       const input = {
 34 |         calendarId: 'primary',
 35 |         timeMin: '2024-01-01T00:00:00',
 36 |         timeMax: '2024-01-02T00:00:00'
 37 |       };
 38 | 
 39 |       const result = ToolSchemas['list-events'].safeParse(input);
 40 |       expect(result.success).toBe(true);
 41 |       expect(result.data?.calendarId).toBe('primary');
 42 |     });
 43 | 
 44 |     it('should validate JSON string format', () => {
 45 |       const input = {
 46 |         calendarId: '["primary", "[email protected]"]',
 47 |         timeMin: '2024-01-01T00:00:00',
 48 |         timeMax: '2024-01-02T00:00:00'
 49 |       };
 50 | 
 51 |       const result = ToolSchemas['list-events'].safeParse(input);
 52 |       expect(result.success).toBe(true);
 53 |       expect(result.data?.calendarId).toBe('["primary", "[email protected]"]');
 54 |     });
 55 |   });
 56 | 
 57 |   describe('Array validation constraints', () => {
 58 |     it('should enforce minimum array length', () => {
 59 |       const input = {
 60 |         calendarId: [],
 61 |         timeMin: '2024-01-01T00:00:00',
 62 |         timeMax: '2024-01-02T00:00:00'
 63 |       };
 64 | 
 65 |       const result = ToolSchemas['list-events'].safeParse(input);
 66 |       expect(result.success).toBe(false);
 67 |       if (!result.success) {
 68 |         expect(result.error.issues[0].message).toContain('At least one calendar ID is required');
 69 |       }
 70 |     });
 71 | 
 72 |     it('should enforce maximum array length', () => {
 73 |       const input = {
 74 |         calendarId: Array(51).fill('calendar'),
 75 |         timeMin: '2024-01-01T00:00:00',
 76 |         timeMax: '2024-01-02T00:00:00'
 77 |       };
 78 | 
 79 |       const result = ToolSchemas['list-events'].safeParse(input);
 80 |       expect(result.success).toBe(false);
 81 |       if (!result.success) {
 82 |         expect(result.error.issues[0].message).toContain('Maximum 50 calendars');
 83 |       }
 84 |     });
 85 | 
 86 |     it('should reject duplicate calendar IDs in array', () => {
 87 |       const input = {
 88 |         calendarId: ['primary', 'primary'],
 89 |         timeMin: '2024-01-01T00:00:00',
 90 |         timeMax: '2024-01-02T00:00:00'
 91 |       };
 92 | 
 93 |       const result = ToolSchemas['list-events'].safeParse(input);
 94 |       expect(result.success).toBe(false);
 95 |       if (!result.success) {
 96 |         expect(result.error.issues[0].message).toContain('Duplicate calendar IDs');
 97 |       }
 98 |     });
 99 | 
100 |     it('should reject empty strings in array', () => {
101 |       const input = {
102 |         calendarId: ['primary', ''],
103 |         timeMin: '2024-01-01T00:00:00',
104 |         timeMax: '2024-01-02T00:00:00'
105 |       };
106 | 
107 |       const result = ToolSchemas['list-events'].safeParse(input);
108 |       expect(result.success).toBe(false);
109 |     });
110 |   });
111 | 
112 |   describe('Type preservation after validation', () => {
113 |     it('should preserve array type for native arrays (issue #95 fix)', () => {
114 |       const input = {
115 |         calendarId: ['primary', '[email protected]', '[email protected]'],
116 |         timeMin: '2024-01-01T00:00:00',
117 |         timeMax: '2024-01-02T00:00:00'
118 |       };
119 | 
120 |       const result = ToolSchemas['list-events'].parse(input);
121 | 
122 |       // The key fix: arrays should NOT be transformed to JSON strings by the schema
123 |       // The handlerFunction will handle the conversion logic
124 |       expect(Array.isArray(result.calendarId)).toBe(true);
125 |       expect(result.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
126 |     });
127 | 
128 |     it('should preserve string type for single strings', () => {
129 |       const input = {
130 |         calendarId: 'primary',
131 |         timeMin: '2024-01-01T00:00:00',
132 |         timeMax: '2024-01-02T00:00:00'
133 |       };
134 | 
135 |       const result = ToolSchemas['list-events'].parse(input);
136 |       expect(typeof result.calendarId).toBe('string');
137 |       expect(result.calendarId).toBe('primary');
138 |     });
139 | 
140 |     it('should preserve string type for JSON strings', () => {
141 |       const input = {
142 |         calendarId: '["primary", "[email protected]"]',
143 |         timeMin: '2024-01-01T00:00:00',
144 |         timeMax: '2024-01-02T00:00:00'
145 |       };
146 | 
147 |       const result = ToolSchemas['list-events'].parse(input);
148 |       expect(typeof result.calendarId).toBe('string');
149 |       expect(result.calendarId).toBe('["primary", "[email protected]"]');
150 |     });
151 |   });
152 | 
153 |   describe('Real-world scenarios from issue #95', () => {
154 |     it('should handle exact input from Home Assistant multi-mcp', () => {
155 |       // This is the exact format that was failing in issue #95
156 |       const input = {
157 |         calendarId: ['primary', '[email protected]', '[email protected]', '[email protected]', '[email protected]'],
158 |         timeMin: '2025-10-09T00:00:00',
159 |         timeMax: '2025-10-09T23:59:59'
160 |       };
161 | 
162 |       const result = ToolSchemas['list-events'].safeParse(input);
163 |       expect(result.success).toBe(true);
164 |       expect(Array.isArray(result.data?.calendarId)).toBe(true);
165 |       expect(result.data?.calendarId).toHaveLength(5);
166 |     });
167 | 
168 |     it('should handle mixed special characters in calendar IDs', () => {
169 |       const input = {
170 |         calendarId: ['primary', '[email protected]', '[email protected]'],
171 |         timeMin: '2024-01-01T00:00:00',
172 |         timeMax: '2024-01-02T00:00:00'
173 |       };
174 | 
175 |       const result = ToolSchemas['list-events'].safeParse(input);
176 |       expect(result.success).toBe(true);
177 |       expect(result.data?.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
178 |     });
179 | 
180 |     it('should accept single-quoted JSON string format (Python/shell style)', () => {
181 |       // Some clients may send JSON-like strings with single quotes instead of double quotes
182 |       // e.g., from Python str() representation or shell scripts
183 |       // The schema should accept it as a string (handlerFunction will process it)
184 |       const input = {
185 |         calendarId: "['primary', '[email protected]']",
186 |         timeMin: '2025-10-09T00:00:00',
187 |         timeMax: '2025-10-09T23:59:59'
188 |       };
189 | 
190 |       // Schema should accept it as a string (not reject it)
191 |       const result = ToolSchemas['list-events'].safeParse(input);
192 |       expect(result.success).toBe(true);
193 |       expect(typeof result.data?.calendarId).toBe('string');
194 |       expect(result.data?.calendarId).toBe("['primary', '[email protected]']");
195 |     });
196 |   });
197 | 
198 |   // HandlerFunction tests - second step after schema validation
199 |   if (!handlerFunction) {
200 |     console.warn('⚠️  handlerFunction not found - skipping handler tests');
201 |   } else {
202 |     describe('HandlerFunction preprocessing (second step)', () => {
203 |       describe('Format handling', () => {
204 |         it('should pass through native arrays unchanged', async () => {
205 |           const input = {
206 |             calendarId: ['primary', '[email protected]'],
207 |             timeMin: '2024-01-01T00:00:00',
208 |             timeMax: '2024-01-02T00:00:00'
209 |           };
210 | 
211 |           const result = await handlerFunction(input);
212 |           expect(Array.isArray(result.calendarId)).toBe(true);
213 |           expect(result.calendarId).toEqual(['primary', '[email protected]']);
214 |         });
215 | 
216 |         it('should pass through single strings unchanged', async () => {
217 |           const input = {
218 |             calendarId: 'primary',
219 |             timeMin: '2024-01-01T00:00:00',
220 |             timeMax: '2024-01-02T00:00:00'
221 |           };
222 | 
223 |           const result = await handlerFunction(input);
224 |           expect(typeof result.calendarId).toBe('string');
225 |           expect(result.calendarId).toBe('primary');
226 |         });
227 | 
228 |         it('should parse valid JSON strings with double quotes', async () => {
229 |           const input = {
230 |             calendarId: '["primary", "[email protected]"]',
231 |             timeMin: '2024-01-01T00:00:00',
232 |             timeMax: '2024-01-02T00:00:00'
233 |           };
234 | 
235 |           const result = await handlerFunction(input);
236 |           expect(Array.isArray(result.calendarId)).toBe(true);
237 |           expect(result.calendarId).toEqual(['primary', '[email protected]']);
238 |         });
239 | 
240 |         it('should parse single-quoted JSON-like strings (Python/shell style) - THE KEY FIX', async () => {
241 |           // This is the failing case that needed fixing
242 |           const input = {
243 |             calendarId: "['primary', '[email protected]']",
244 |             timeMin: '2025-10-09T00:00:00',
245 |             timeMax: '2025-10-09T23:59:59'
246 |           };
247 | 
248 |           const result = await handlerFunction(input);
249 |           expect(Array.isArray(result.calendarId)).toBe(true);
250 |           expect(result.calendarId).toEqual(['primary', '[email protected]']);
251 |         });
252 | 
253 |         it('should handle calendar IDs with apostrophes in single-quoted JSON', async () => {
254 |           // Calendar IDs can contain apostrophes (e.g., "John's Calendar")
255 |           // Our replacement logic should not break these
256 |           const input = {
257 |             calendarId: "['primary', '[email protected]']",
258 |             timeMin: '2024-01-01T00:00:00',
259 |             timeMax: '2024-01-02T00:00:00'
260 |           };
261 | 
262 |           const result = await handlerFunction(input);
263 |           expect(Array.isArray(result.calendarId)).toBe(true);
264 |           expect(result.calendarId).toEqual(['primary', '[email protected]']);
265 |         });
266 | 
267 |         it('should handle JSON strings with whitespace', async () => {
268 |           const input = {
269 |             calendarId: '  ["primary", "[email protected]"]  ',
270 |             timeMin: '2024-01-01T00:00:00',
271 |             timeMax: '2024-01-02T00:00:00'
272 |           };
273 | 
274 |           const result = await handlerFunction(input);
275 |           expect(Array.isArray(result.calendarId)).toBe(true);
276 |           expect(result.calendarId).toEqual(['primary', '[email protected]']);
277 |         });
278 |       });
279 | 
280 |       describe('JSON string validation', () => {
281 |         it('should reject empty arrays in JSON strings', async () => {
282 |           const input = {
283 |             calendarId: '[]',
284 |             timeMin: '2024-01-01T00:00:00',
285 |             timeMax: '2024-01-02T00:00:00'
286 |           };
287 | 
288 |           await expect(handlerFunction(input)).rejects.toThrow('At least one calendar ID is required');
289 |         });
290 | 
291 |         it('should reject arrays exceeding 50 calendars', async () => {
292 |           const input = {
293 |             calendarId: JSON.stringify(Array(51).fill('calendar')),
294 |             timeMin: '2024-01-01T00:00:00',
295 |             timeMax: '2024-01-02T00:00:00'
296 |           };
297 | 
298 |           await expect(handlerFunction(input)).rejects.toThrow('Maximum 50 calendars');
299 |         });
300 | 
301 |         it('should reject duplicate calendar IDs in JSON strings', async () => {
302 |           const input = {
303 |             calendarId: '["primary", "primary"]',
304 |             timeMin: '2024-01-01T00:00:00',
305 |             timeMax: '2024-01-02T00:00:00'
306 |           };
307 | 
308 |           await expect(handlerFunction(input)).rejects.toThrow('Duplicate calendar IDs');
309 |         });
310 |       });
311 | 
312 |       describe('Error handling', () => {
313 |         it('should provide clear error for malformed JSON array', async () => {
314 |           const input = {
315 |             calendarId: '["primary", "missing-quote}]',
316 |             timeMin: '2024-01-01T00:00:00',
317 |             timeMax: '2024-01-02T00:00:00'
318 |           };
319 | 
320 |           await expect(handlerFunction(input)).rejects.toThrow('Invalid JSON format for calendarId');
321 |         });
322 | 
323 |         it('should reject JSON arrays with non-string elements', async () => {
324 |           const input = {
325 |             calendarId: '["primary", 123, null]',
326 |             timeMin: '2024-01-01T00:00:00',
327 |             timeMax: '2024-01-02T00:00:00'
328 |           };
329 | 
330 |           await expect(handlerFunction(input)).rejects.toThrow('Array must contain only non-empty strings');
331 |         });
332 |       });
333 |     });
334 |   }
335 | });
336 | 
```
Page 3/6FirstPrevNextLast