This is page 2 of 5. Use http://codebase.md/nspady/google-calendar-mcp?page={x} to view the full context.
# Directory Structure
```
├── .cursorignore
├── .dockerignore
├── .env.example
├── .github
│ └── workflows
│ ├── ci.yml
│ ├── publish.yml
│ └── README.md
├── .gitignore
├── .release-please-manifest.json
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── advanced-usage.md
│ ├── architecture.md
│ ├── authentication.md
│ ├── deployment.md
│ ├── development.md
│ ├── docker.md
│ ├── README.md
│ └── testing.md
├── examples
│ ├── http-client.js
│ └── http-with-curl.sh
├── future_features
│ └── ARCHITECTURE_REDESIGN.md
├── gcp-oauth.keys.example.json
├── instructions
│ └── file_structure.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── account-manager.js
│ ├── build.js
│ ├── dev.js
│ └── test-docker.sh
├── src
│ ├── auth
│ │ ├── client.ts
│ │ ├── paths.d.ts
│ │ ├── paths.js
│ │ ├── server.ts
│ │ ├── tokenManager.ts
│ │ └── utils.ts
│ ├── auth-server.ts
│ ├── config
│ │ └── TransportConfig.ts
│ ├── handlers
│ │ ├── core
│ │ │ ├── BaseToolHandler.ts
│ │ │ ├── BatchRequestHandler.ts
│ │ │ ├── CreateEventHandler.ts
│ │ │ ├── DeleteEventHandler.ts
│ │ │ ├── FreeBusyEventHandler.ts
│ │ │ ├── GetCurrentTimeHandler.ts
│ │ │ ├── GetEventHandler.ts
│ │ │ ├── ListCalendarsHandler.ts
│ │ │ ├── ListColorsHandler.ts
│ │ │ ├── ListEventsHandler.ts
│ │ │ ├── RecurringEventHelpers.ts
│ │ │ ├── SearchEventsHandler.ts
│ │ │ └── UpdateEventHandler.ts
│ │ ├── utils
│ │ │ └── datetime.ts
│ │ └── utils.ts
│ ├── index.ts
│ ├── schemas
│ │ └── types.ts
│ ├── server.ts
│ ├── services
│ │ └── conflict-detection
│ │ ├── config.ts
│ │ ├── ConflictAnalyzer.ts
│ │ ├── ConflictDetectionService.ts
│ │ ├── EventSimilarityChecker.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── tests
│ │ ├── integration
│ │ │ ├── claude-mcp-integration.test.ts
│ │ │ ├── direct-integration.test.ts
│ │ │ ├── docker-integration.test.ts
│ │ │ ├── openai-mcp-integration.test.ts
│ │ │ └── test-data-factory.ts
│ │ └── unit
│ │ ├── console-statements.test.ts
│ │ ├── handlers
│ │ │ ├── BatchListEvents.test.ts
│ │ │ ├── BatchRequestHandler.test.ts
│ │ │ ├── CalendarNameResolution.test.ts
│ │ │ ├── create-event-blocking.test.ts
│ │ │ ├── CreateEventHandler.test.ts
│ │ │ ├── datetime-utils.test.ts
│ │ │ ├── duplicate-event-display.test.ts
│ │ │ ├── GetCurrentTimeHandler.test.ts
│ │ │ ├── GetEventHandler.test.ts
│ │ │ ├── list-events-registry.test.ts
│ │ │ ├── ListEventsHandler.test.ts
│ │ │ ├── RecurringEventHelpers.test.ts
│ │ │ ├── UpdateEventHandler.recurring.test.ts
│ │ │ ├── UpdateEventHandler.test.ts
│ │ │ ├── utils-conflict-format.test.ts
│ │ │ └── utils.test.ts
│ │ ├── index.test.ts
│ │ ├── schemas
│ │ │ ├── enhanced-properties.test.ts
│ │ │ ├── no-refs.test.ts
│ │ │ ├── schema-compatibility.test.ts
│ │ │ ├── tool-registration.test.ts
│ │ │ └── validators.test.ts
│ │ ├── services
│ │ │ └── conflict-detection
│ │ │ ├── ConflictAnalyzer.test.ts
│ │ │ └── EventSimilarityChecker.test.ts
│ │ └── utils
│ │ ├── event-id-validator.test.ts
│ │ └── field-mask-builder.test.ts
│ ├── tools
│ │ └── registry.ts
│ ├── transports
│ │ ├── http.ts
│ │ └── stdio.ts
│ ├── types
│ │ └── structured-responses.ts
│ └── utils
│ ├── event-id-validator.ts
│ ├── field-mask-builder.ts
│ └── response-builder.ts
├── tsconfig.json
├── tsconfig.lint.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/tests/unit/handlers/GetCurrentTimeHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi } from 'vitest';
import { GetCurrentTimeHandler } from '../../../handlers/core/GetCurrentTimeHandler.js';
import { OAuth2Client } from 'google-auth-library';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
describe('GetCurrentTimeHandler', () => {
const mockOAuth2Client = {
getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
} as unknown as OAuth2Client;
describe('runTool', () => {
it('should return current time without timezone parameter using primary calendar timezone', async () => {
const handler = new GetCurrentTimeHandler();
// Mock calendar timezone to avoid real API calls in unit tests
const spy = vi.spyOn(GetCurrentTimeHandler.prototype as any, 'getCalendarTimezone').mockResolvedValue('America/Los_Angeles');
const result = await handler.runTool({}, mockOAuth2Client);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse(result.content[0].text as string);
expect(response.currentTime).toBeDefined();
expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(response.timezone).toBe('America/Los_Angeles');
expect(response.offset).toBeDefined();
expect(response.isDST).toBeTypeOf('boolean');
spy.mockRestore();
});
it('should return current time with valid timezone parameter', async () => {
const handler = new GetCurrentTimeHandler();
const result = await handler.runTool({ timeZone: 'America/New_York' }, mockOAuth2Client);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse(result.content[0].text as string);
expect(response.currentTime).toBeDefined();
expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(response.timezone).toBe('America/New_York');
expect(response.offset).toBeDefined();
expect(response.isDST).toBeTypeOf('boolean');
});
it('should handle UTC timezone parameter', async () => {
const handler = new GetCurrentTimeHandler();
const result = await handler.runTool({ timeZone: 'UTC' }, mockOAuth2Client);
const response = JSON.parse(result.content[0].text as string);
expect(response.timezone).toBe('UTC');
expect(response.offset).toBe('Z');
expect(response.isDST).toBe(false);
});
it('should throw error for invalid timezone', async () => {
const handler = new GetCurrentTimeHandler();
await expect(handler.runTool({ timeZone: 'Invalid/Timezone' }, mockOAuth2Client))
.rejects.toThrow(McpError);
try {
await handler.runTool({ timeZone: 'Invalid/Timezone' }, mockOAuth2Client);
} catch (error) {
expect(error).toBeInstanceOf(McpError);
expect((error as McpError).code).toBe(ErrorCode.InvalidRequest);
expect((error as McpError).message).toContain('Invalid timezone');
expect((error as McpError).message).toContain('Invalid/Timezone');
}
});
});
describe('timezone validation', () => {
it('should validate common IANA timezones', async () => {
const handler = new GetCurrentTimeHandler();
const validTimezones = [
'UTC',
'America/Los_Angeles',
'America/New_York',
'Europe/London',
'Asia/Tokyo',
'Australia/Sydney'
];
for (const timezone of validTimezones) {
const result = await handler.runTool({ timeZone: timezone }, mockOAuth2Client);
const response = JSON.parse(result.content[0].text as string);
expect(response.timezone).toBe(timezone);
}
});
it('should reject invalid timezone formats', async () => {
const handler = new GetCurrentTimeHandler();
const invalidTimezones = [
'Pacific/Invalid',
'Not/A/Timezone',
'Invalid/Timezone',
'XYZ',
'foo/bar'
];
for (const timezone of invalidTimezones) {
await expect(handler.runTool({ timeZone: timezone }, mockOAuth2Client))
.rejects.toThrow(McpError);
}
});
});
describe('output format', () => {
it('should include all required fields in response without timezone', async () => {
const handler = new GetCurrentTimeHandler();
const spy = vi.spyOn(GetCurrentTimeHandler.prototype as any, 'getCalendarTimezone').mockResolvedValue('America/Los_Angeles');
const result = await handler.runTool({}, mockOAuth2Client);
const response = JSON.parse(result.content[0].text as string);
expect(response).toHaveProperty('currentTime');
expect(response).toHaveProperty('timezone');
expect(response).toHaveProperty('offset');
expect(response).toHaveProperty('isDST');
expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(response.timezone).toBe('America/Los_Angeles');
expect(response.isDST).toBeTypeOf('boolean');
spy.mockRestore();
});
it('should include all required fields in response with timezone', async () => {
const handler = new GetCurrentTimeHandler();
const result = await handler.runTool({ timeZone: 'UTC' }, mockOAuth2Client);
const response = JSON.parse(result.content[0].text as string);
expect(response).toHaveProperty('currentTime');
expect(response).toHaveProperty('timezone');
expect(response).toHaveProperty('offset');
expect(response).toHaveProperty('isDST');
expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(response.timezone).toBe('UTC');
expect(response.offset).toBe('Z');
expect(response.isDST).toBe(false);
});
it('should format RFC3339 timestamps correctly', async () => {
const handler = new GetCurrentTimeHandler();
const result = await handler.runTool({ timeZone: 'UTC' }, mockOAuth2Client);
const response = JSON.parse(result.content[0].text as string);
// Should match ISO8601 pattern
expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
// Offset should be in the format +HH:MM, -HH:MM, or Z
expect(response.offset).toMatch(/^([+-]\d{2}:\d{2}|Z)$/);
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/console-statements.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Test to ensure no console.log statements exist in the source code
* This helps maintain MCP compliance since console statements are not supported in MCP clients
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, extname } from 'path';
// Files and directories that are allowed to have console statements
const ALLOWED_CONSOLE_FILES = [
// Example files are allowed to have console statements
'examples/',
// Build scripts are allowed to have console statements
'scripts/',
// Test files can have console statements for debugging
'.test.ts',
'.test.js',
// Documentation files
'.md',
// Config files
'vitest.config.ts',
'tsconfig.json'
];
// Console methods that should not be present in source code
const FORBIDDEN_CONSOLE_METHODS = [
'console.log',
'console.warn',
'console.info',
'console.debug',
'console.trace',
'console.time',
'console.timeEnd',
'console.table',
'console.assert',
'console.clear',
'console.count',
'console.countReset',
'console.dir',
'console.dirxml',
'console.group',
'console.groupCollapsed',
'console.groupEnd'
];
interface ConsoleFinding {
file: string;
line: number;
content: string;
method: string;
}
/**
* Recursively get all TypeScript and JavaScript files in a directory
*/
function getSourceFiles(dir: string, basePath: string = ''): string[] {
const files: string[] = [];
const items = readdirSync(dir);
for (const item of items) {
const fullPath = join(dir, item);
const relativePath = join(basePath, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
// Skip node_modules and other non-source directories
if (!['node_modules', '.git', 'build', 'dist', 'coverage'].includes(item)) {
files.push(...getSourceFiles(fullPath, relativePath));
}
} else if (stat.isFile()) {
const ext = extname(item);
if (['.ts', '.js', '.mjs', '.cjs'].includes(ext)) {
files.push(relativePath);
}
}
}
return files;
}
/**
* Check if a file should be excluded from console statement checking
*/
function isFileAllowed(filePath: string): boolean {
return ALLOWED_CONSOLE_FILES.some(allowedPath =>
filePath.includes(allowedPath) || filePath.endsWith(allowedPath)
);
}
/**
* Find console statements in a file
*/
function findConsoleStatements(filePath: string, content: string): ConsoleFinding[] {
const findings: ConsoleFinding[] = [];
const lines = content.split('\n');
lines.forEach((line, index) => {
const trimmedLine = line.trim();
// Skip comments
if (trimmedLine.startsWith('//') || trimmedLine.startsWith('*') || trimmedLine.startsWith('/*')) {
return;
}
// Check for console statements
for (const method of FORBIDDEN_CONSOLE_METHODS) {
if (line.includes(method)) {
// Make sure it's not in a string literal or comment
const methodIndex = line.indexOf(method);
const beforeMethod = line.substring(0, methodIndex);
// Simple check to avoid false positives in strings
const singleQuotes = (beforeMethod.match(/'/g) || []).length;
const doubleQuotes = (beforeMethod.match(/"/g) || []).length;
const backticks = (beforeMethod.match(/`/g) || []).length;
// If we're inside quotes, skip this match
if (singleQuotes % 2 === 1 || doubleQuotes % 2 === 1 || backticks % 2 === 1) {
continue;
}
findings.push({
file: filePath,
line: index + 1,
content: line.trim(),
method
});
}
}
});
return findings;
}
describe('Console Statement Detection', () => {
it('should not contain any console.log statements in source code', () => {
const sourceFiles = getSourceFiles('./src');
const allFindings: ConsoleFinding[] = [];
for (const file of sourceFiles) {
// Skip files that are allowed to have console statements
if (isFileAllowed(file)) {
continue;
}
try {
const fullPath = join('./src', file);
const content = readFileSync(fullPath, 'utf-8');
const findings = findConsoleStatements(file, content);
allFindings.push(...findings);
} catch (error) {
// Skip files that can't be read
continue;
}
}
// Create a detailed error message if console statements are found
if (allFindings.length > 0) {
const errorMessage = [
`Found ${allFindings.length} console statement(s) in source code:`,
'',
...allFindings.map(finding =>
` ${finding.file}:${finding.line} - ${finding.method} in: ${finding.content}`
),
'',
'Console statements are not supported in MCP clients.',
'Use process.stderr.write() for error logging instead.',
'For debugging, consider using a proper logging library or remove before committing.'
].join('\n');
expect(allFindings).toEqual([]);
throw new Error(errorMessage);
}
expect(allFindings).toEqual([]);
});
it('should properly detect console statements in test content', () => {
// Test the detection logic itself with some sample content
const testContent = `
function test() {
console.log("This should be detected");
const x = "console.log in string should be ignored";
// console.warn("This is a comment, should be ignored");
console.warn("This should be detected");
console.error("This is now allowed and should not be detected");
process.stderr.write("This is allowed\\n");
}
`;
const findings = findConsoleStatements('test-file.ts', testContent);
expect(findings).toHaveLength(2);
expect(findings[0].method).toBe('console.log');
expect(findings[1].method).toBe('console.warn');
});
it('should ignore console statements in strings and comments', () => {
const testContent = `
// This console.log is in a comment
const message = "Use console.log for debugging";
const template = \`Don't use console.error here\`;
/* console.warn in block comment */
`;
const findings = findConsoleStatements('test-file.ts', testContent);
// Should not find any console statements since they're all in comments or strings
expect(findings).toHaveLength(0);
});
});
```
--------------------------------------------------------------------------------
/src/utils/response-builder.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { ConflictCheckResult } from "../services/conflict-detection/types.js";
import {
ConflictInfo,
DuplicateInfo,
convertGoogleEventToStructured,
StructuredEvent
} from "../types/structured-responses.js";
import { calendar_v3 } from "googleapis";
/**
* Creates a structured JSON response for MCP tools
*
* Note: We use compact JSON (no pretty-printing) because MCP clients
* are expected to parse and display the JSON themselves. Pretty-printing
* with escaped newlines (\n) creates poor display in clients that show
* the raw text.
*/
export function createStructuredResponse<T>(data: T): CallToolResult {
return {
content: [{
type: "text",
text: JSON.stringify(data)
}]
};
}
/**
* Converts conflict check results to structured format
*/
export function convertConflictsToStructured(
conflicts: ConflictCheckResult
): { conflicts?: ConflictInfo[]; duplicates?: DuplicateInfo[] } {
const result: { conflicts?: ConflictInfo[]; duplicates?: DuplicateInfo[] } = {};
if (conflicts.duplicates.length > 0) {
result.duplicates = conflicts.duplicates.map(dup => {
// Get start and end from fullEvent if available
let start = '';
let end = '';
if (dup.fullEvent) {
start = dup.fullEvent.start?.dateTime || dup.fullEvent.start?.date || '';
end = dup.fullEvent.end?.dateTime || dup.fullEvent.end?.date || '';
}
return {
event: {
id: dup.event.id || '',
title: dup.event.title,
start,
end,
url: dup.event.url,
similarity: dup.event.similarity
},
calendarId: dup.calendarId || '',
suggestion: dup.suggestion
};
});
}
if (conflicts.conflicts.length > 0) {
result.conflicts = conflicts.conflicts.map(conflict => {
// Get start and end from either the event object or fullEvent
let start = conflict.event.start || '';
let end = conflict.event.end || '';
if (!start && conflict.fullEvent) {
start = conflict.fullEvent.start?.dateTime || conflict.fullEvent.start?.date || '';
}
if (!end && conflict.fullEvent) {
end = conflict.fullEvent.end?.dateTime || conflict.fullEvent.end?.date || '';
}
return {
event: {
id: conflict.event.id || '',
title: conflict.event.title,
start,
end,
url: conflict.event.url,
similarity: conflict.similarity
},
calendar: conflict.calendar,
overlap: conflict.overlap ? {
duration: conflict.overlap.duration,
percentage: `${conflict.overlap.percentage}%`
} : undefined
};
});
}
return result;
}
/**
* Converts an array of Google Calendar events to structured format
*/
export function convertEventsToStructured(
events: calendar_v3.Schema$Event[],
calendarId?: string
): StructuredEvent[] {
return events.map(event => convertGoogleEventToStructured(event, calendarId));
}
/**
* Helper to add calendar ID to events
*/
export function addCalendarIdToEvents(
events: calendar_v3.Schema$Event[],
calendarId: string
): StructuredEvent[] {
return events.map(event => ({
...convertGoogleEventToStructured(event),
calendarId
}));
}
/**
* Formats free/busy information into structured format
*/
export function formatFreeBusyStructured(
freeBusy: any,
timeMin: string,
timeMax: string
): {
timeMin: string;
timeMax: string;
calendars: Record<string, {
busy: Array<{ start: string; end: string }>;
errors?: Array<{ domain?: string; reason?: string }>;
}>;
} {
const calendars: Record<string, any> = {};
if (freeBusy.calendars) {
for (const [calId, calData] of Object.entries(freeBusy.calendars) as [string, any][]) {
calendars[calId] = {
busy: calData.busy?.map((slot: any) => ({
start: slot.start,
end: slot.end
})) || []
};
if (calData.errors?.length > 0) {
calendars[calId].errors = calData.errors;
}
}
}
return {
timeMin,
timeMax,
calendars
};
}
/**
* Converts calendar list to structured format
*/
export function convertCalendarsToStructured(
calendars: calendar_v3.Schema$CalendarListEntry[]
): Array<{
id: string;
summary?: string;
description?: string;
location?: string;
timeZone?: string;
summaryOverride?: string;
colorId?: string;
backgroundColor?: string;
foregroundColor?: string;
hidden?: boolean;
selected?: boolean;
accessRole?: string;
defaultReminders?: Array<{ method: 'email' | 'popup'; minutes: number }>;
notificationSettings?: {
notifications?: Array<{ type?: string; method?: string }>;
};
primary?: boolean;
deleted?: boolean;
conferenceProperties?: {
allowedConferenceSolutionTypes?: string[];
};
}> {
return calendars.map(cal => ({
id: cal.id || '',
summary: cal.summary ?? undefined,
description: cal.description ?? undefined,
location: cal.location ?? undefined,
timeZone: cal.timeZone ?? undefined,
summaryOverride: cal.summaryOverride ?? undefined,
colorId: cal.colorId ?? undefined,
backgroundColor: cal.backgroundColor ?? undefined,
foregroundColor: cal.foregroundColor ?? undefined,
hidden: cal.hidden ?? undefined,
selected: cal.selected ?? undefined,
accessRole: cal.accessRole ?? undefined,
defaultReminders: cal.defaultReminders?.map(r => ({
method: (r.method as 'email' | 'popup') || 'popup',
minutes: r.minutes || 0
})),
notificationSettings: cal.notificationSettings ? {
notifications: cal.notificationSettings.notifications?.map(n => ({
type: n.type ?? undefined,
method: n.method ?? undefined
}))
} : undefined,
primary: cal.primary ?? undefined,
deleted: cal.deleted ?? undefined,
conferenceProperties: cal.conferenceProperties ? {
allowedConferenceSolutionTypes: cal.conferenceProperties.allowedConferenceSolutionTypes ?? undefined
} : undefined
}));
}
/**
* Creates a warning message for conflicts/duplicates
*/
export function createWarningsArray(conflicts?: ConflictCheckResult): string[] | undefined {
if (!conflicts || !conflicts.hasConflicts) {
return undefined;
}
const warnings: string[] = [];
if (conflicts.duplicates.length > 0) {
warnings.push(`Found ${conflicts.duplicates.length} potential duplicate(s)`);
}
if (conflicts.conflicts.length > 0) {
warnings.push(`Found ${conflicts.conflicts.length} scheduling conflict(s)`);
}
return warnings.length > 0 ? warnings : undefined;
}
```
--------------------------------------------------------------------------------
/scripts/dev.js:
--------------------------------------------------------------------------------
```javascript
#!/usr/bin/env node
/**
* Development and Advanced Features Helper
* Provides access to advanced scripts without cluttering package.json
*/
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
// Command definitions
const commands = {
// HTTP Transport
'http': {
description: 'Start HTTP server on localhost:3000',
cmd: 'node',
args: ['build/index.js', '--transport', 'http', '--port', '3000']
},
'http:public': {
description: 'Start HTTP server accessible from any host',
cmd: 'node',
args: ['build/index.js', '--transport', 'http', '--port', '3000', '--host', '0.0.0.0']
},
// Authentication Management
'auth': {
description: 'Manually authenticate normal account',
cmd: 'node',
args: ['build/auth-server.js'],
env: { GOOGLE_ACCOUNT_MODE: 'normal' }
},
'auth:test': {
description: 'Authenticate test account',
cmd: 'node',
args: ['build/auth-server.js'],
env: { GOOGLE_ACCOUNT_MODE: 'test' }
},
'account:status': {
description: 'Check account status and configuration',
cmd: 'node',
args: ['scripts/account-manager.js', 'status']
},
'account:clear:normal': {
description: 'Clear normal account tokens',
cmd: 'node',
args: ['scripts/account-manager.js', 'clear', 'normal']
},
'account:clear:test': {
description: 'Clear test account tokens',
cmd: 'node',
args: ['scripts/account-manager.js', 'clear', 'test']
},
// Unit Testing
'test': {
description: 'Run unit tests (excludes integration tests)',
cmd: 'npm',
args: ['test']
},
'test:integration:direct': {
description: 'Run core integration tests (recommended for development)',
cmd: 'npx',
args: ['vitest', 'run', 'src/tests/integration/direct-integration.test.ts']
},
'test:integration:claude': {
description: 'Run Claude + MCP integration tests',
cmd: 'npx',
args: ['vitest', 'run', 'src/tests/integration/claude-mcp-integration.test.ts']
},
'test:integration:openai': {
description: 'Run OpenAI + MCP integration tests',
cmd: 'npx',
args: ['vitest', 'run', 'src/tests/integration/openai-mcp-integration.test.ts']
},
'test:integration:all': {
description: 'Run complete integration test suite',
cmd: 'npm',
args: ['run', 'test:integration']
},
'test:watch:all': {
description: 'Run all tests in watch mode',
cmd: 'npx',
args: ['vitest']
},
// Coverage and Analysis
'coverage': {
description: 'Generate test coverage report',
cmd: 'npm',
args: ['run', 'test:coverage']
},
// Docker Operations
'docker:build': {
description: 'Build Docker image',
cmd: 'docker',
args: ['compose', 'build']
},
'docker:up': {
description: 'Start Docker container (stdio mode)',
cmd: 'docker',
args: ['compose', 'up']
},
'docker:up:http': {
description: 'Start Docker container in HTTP mode (edit docker-compose.yml first)',
cmd: 'docker',
args: ['compose', 'up']
},
'docker:down': {
description: 'Stop and remove Docker container',
cmd: 'docker',
args: ['compose', 'down']
},
'docker:logs': {
description: 'View Docker container logs',
cmd: 'docker',
args: ['compose', 'logs', '-f']
},
'docker:exec': {
description: 'Execute commands in running Docker container',
cmd: 'docker',
args: ['compose', 'exec', 'calendar-mcp', 'sh']
},
'docker:auth': {
description: 'Authenticate OAuth in Docker container',
cmd: 'docker',
args: ['compose', 'exec', 'calendar-mcp', 'npm', 'run', 'auth']
},
'docker:test:quick': {
description: 'Run quick Docker tests (no OAuth required)',
cmd: 'bash',
args: ['scripts/test-docker.sh', '--quick']
},
};
function showHelp() {
console.log('🛠️ Google Calendar MCP - Development Helper\n');
console.log('Usage: npm run dev <command>\n');
console.log('Available commands:\n');
const categories = {
'HTTP Transport': ['http', 'http:public'],
'Authentication': ['auth', 'auth:test', 'account:status', 'account:clear:normal', 'account:clear:test'],
'Unit Testing': ['test'],
'Integration Testing': ['test:integration:direct', 'test:integration:claude', 'test:integration:openai', 'test:integration:all', 'test:watch:all'],
'Docker Operations': ['docker:build', 'docker:up', 'docker:up:http', 'docker:auth', 'docker:logs', 'docker:down', 'docker:exec', 'docker:test:quick'],
'Coverage & Analysis': ['coverage']
};
for (const [category, cmdList] of Object.entries(categories)) {
console.log(`\x1b[1m${category}:\x1b[0m`);
for (const cmd of cmdList) {
if (commands[cmd]) {
console.log(` ${cmd.padEnd(25)} ${commands[cmd].description}`);
}
}
console.log('');
}
console.log('Examples:');
console.log(' npm run dev http # Start HTTP server');
console.log(' npm run dev docker:up # Start Docker container');
console.log(' npm run dev docker:auth # Authenticate in Docker');
console.log(' npm run dev test:integration:direct # Run core integration tests');
}
async function runCommand(commandName) {
const command = commands[commandName];
if (!command) {
console.error(`❌ Unknown command: ${commandName}`);
console.log('\nRun "npm run dev" to see available commands.');
process.exit(1);
}
// Handle preBuild flag
if (command.preBuild) {
console.log(`📦 Building project first...`);
await new Promise((resolve, reject) => {
const buildChild = spawn('npm', ['run', 'build'], {
stdio: 'inherit',
cwd: rootDir
});
buildChild.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Build failed with exit code ${code}`));
} else {
resolve();
}
});
buildChild.on('error', reject);
}).catch(error => {
console.error(`\n❌ Build failed: ${error.message}`);
process.exit(1);
});
console.log(`✅ Build complete\n`);
}
console.log(`🚀 Running: ${commandName}`);
console.log(` Command: ${command.cmd} ${command.args.join(' ')}\n`);
const env = {
...process.env,
...(command.env || {})
};
const child = spawn(command.cmd, command.args, {
stdio: 'inherit',
cwd: rootDir,
env
});
child.on('exit', (code) => {
if (code !== 0) {
console.error(`\n❌ Command failed with exit code ${code}`);
process.exit(code);
}
});
child.on('error', (error) => {
console.error(`❌ Failed to run command: ${error.message}`);
process.exit(1);
});
}
// Main execution
const [,, commandName] = process.argv;
if (!commandName || commandName === 'help' || commandName === '--help') {
showHelp();
} else {
runCommand(commandName);
}
```
--------------------------------------------------------------------------------
/src/handlers/core/CreateEventHandler.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { OAuth2Client } from "google-auth-library";
import { CreateEventInput } from "../../tools/registry.js";
import { BaseToolHandler } from "./BaseToolHandler.js";
import { calendar_v3 } from 'googleapis';
import { createTimeObject } from "../utils/datetime.js";
import { validateEventId } from "../../utils/event-id-validator.js";
import { ConflictDetectionService } from "../../services/conflict-detection/index.js";
import { CONFLICT_DETECTION_CONFIG } from "../../services/conflict-detection/config.js";
import { createStructuredResponse, convertConflictsToStructured, createWarningsArray } from "../../utils/response-builder.js";
import { CreateEventResponse, convertGoogleEventToStructured, DuplicateInfo } from "../../types/structured-responses.js";
export class CreateEventHandler extends BaseToolHandler {
private conflictDetectionService: ConflictDetectionService;
constructor() {
super();
this.conflictDetectionService = new ConflictDetectionService();
}
async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
const validArgs = args as CreateEventInput;
// Create the event object for conflict checking
const timezone = args.timeZone || await this.getCalendarTimezone(oauth2Client, validArgs.calendarId);
const eventToCheck: calendar_v3.Schema$Event = {
summary: args.summary,
description: args.description,
start: createTimeObject(args.start, timezone),
end: createTimeObject(args.end, timezone),
attendees: args.attendees,
location: args.location,
};
// Check for conflicts and duplicates
const conflicts = await this.conflictDetectionService.checkConflicts(
oauth2Client,
eventToCheck,
validArgs.calendarId,
{
checkDuplicates: true,
checkConflicts: true,
calendarsToCheck: validArgs.calendarsToCheck || [validArgs.calendarId],
duplicateSimilarityThreshold: validArgs.duplicateSimilarityThreshold || CONFLICT_DETECTION_CONFIG.DEFAULT_DUPLICATE_THRESHOLD
}
);
// Block creation if exact or near-exact duplicate found
const exactDuplicate = conflicts.duplicates.find(
dup => dup.event.similarity >= CONFLICT_DETECTION_CONFIG.DUPLICATE_THRESHOLDS.BLOCKING
);
if (exactDuplicate && validArgs.allowDuplicates !== true) {
// Create a duplicate error response
const duplicateInfo: DuplicateInfo = {
event: {
id: exactDuplicate.event.id || '',
title: exactDuplicate.event.title,
start: exactDuplicate.event.start || '',
end: exactDuplicate.event.end || '',
url: exactDuplicate.event.url,
similarity: exactDuplicate.event.similarity
},
calendarId: exactDuplicate.calendarId || '',
suggestion: exactDuplicate.suggestion
};
// Throw an error that will be handled by MCP SDK
throw new Error(
`Duplicate event detected (${Math.round(exactDuplicate.event.similarity * 100)}% similar). ` +
`Event "${exactDuplicate.event.title}" already exists. ` +
`To create anyway, set allowDuplicates to true.`
);
}
// Create the event
const event = await this.createEvent(oauth2Client, validArgs);
// Generate structured response with conflict warnings
const structuredConflicts = convertConflictsToStructured(conflicts);
const response: CreateEventResponse = {
event: convertGoogleEventToStructured(event, validArgs.calendarId),
conflicts: structuredConflicts.conflicts,
duplicates: structuredConflicts.duplicates,
warnings: createWarningsArray(conflicts)
};
return createStructuredResponse(response);
}
private async createEvent(
client: OAuth2Client,
args: CreateEventInput
): Promise<calendar_v3.Schema$Event> {
try {
const calendar = this.getCalendar(client);
// Validate custom event ID if provided
if (args.eventId) {
validateEventId(args.eventId);
}
// Use provided timezone or calendar's default timezone
const timezone = args.timeZone || await this.getCalendarTimezone(client, args.calendarId);
const requestBody: calendar_v3.Schema$Event = {
summary: args.summary,
description: args.description,
start: createTimeObject(args.start, timezone),
end: createTimeObject(args.end, timezone),
attendees: args.attendees,
location: args.location,
colorId: args.colorId,
reminders: args.reminders,
recurrence: args.recurrence,
transparency: args.transparency,
visibility: args.visibility,
guestsCanInviteOthers: args.guestsCanInviteOthers,
guestsCanModify: args.guestsCanModify,
guestsCanSeeOtherGuests: args.guestsCanSeeOtherGuests,
anyoneCanAddSelf: args.anyoneCanAddSelf,
conferenceData: args.conferenceData,
extendedProperties: args.extendedProperties,
attachments: args.attachments,
source: args.source,
...(args.eventId && { id: args.eventId }) // Include custom ID if provided
};
// Determine if we need to enable conference data or attachments
const conferenceDataVersion = args.conferenceData ? 1 : undefined;
const supportsAttachments = args.attachments ? true : undefined;
const response = await calendar.events.insert({
calendarId: args.calendarId,
requestBody: requestBody,
sendUpdates: args.sendUpdates,
...(conferenceDataVersion && { conferenceDataVersion }),
...(supportsAttachments && { supportsAttachments })
});
if (!response.data) throw new Error('Failed to create event, no data returned');
return response.data;
} catch (error: any) {
// Handle ID conflict errors specifically
if (error?.code === 409 || error?.response?.status === 409) {
throw new Error(`Event ID '${args.eventId}' already exists. Please use a different ID.`);
}
throw this.handleGoogleApiError(error);
}
}
}
```
--------------------------------------------------------------------------------
/src/handlers/core/RecurringEventHelpers.ts:
--------------------------------------------------------------------------------
```typescript
import { calendar_v3 } from 'googleapis';
import { createTimeObject } from '../utils/datetime.js';
export class RecurringEventHelpers {
private calendar: calendar_v3.Calendar;
constructor(calendar: calendar_v3.Calendar) {
this.calendar = calendar;
}
/**
* Get the calendar instance
*/
getCalendar(): calendar_v3.Calendar {
return this.calendar;
}
/**
* Detects if an event is recurring or single
*/
async detectEventType(eventId: string, calendarId: string): Promise<'recurring' | 'single'> {
const response = await this.calendar.events.get({
calendarId,
eventId
});
const event = response.data;
return event.recurrence && event.recurrence.length > 0 ? 'recurring' : 'single';
}
/**
* Formats an instance ID for single instance updates
*/
formatInstanceId(eventId: string, originalStartTime: string): string {
// Convert to UTC first, then format to basic format: YYYYMMDDTHHMMSSZ
const utcDate = new Date(originalStartTime);
const basicTimeFormat = utcDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
return `${eventId}_${basicTimeFormat}`;
}
/**
* Calculates the UNTIL date for future instance updates
*/
calculateUntilDate(futureStartDate: string): string {
const futureDate = new Date(futureStartDate);
const untilDate = new Date(futureDate.getTime() - 86400000); // -1 day
return untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
}
/**
* Calculates end time based on original duration
*/
calculateEndTime(newStartTime: string, originalEvent: calendar_v3.Schema$Event): string {
const newStart = new Date(newStartTime);
const originalStart = new Date(originalEvent.start!.dateTime!);
const originalEnd = new Date(originalEvent.end!.dateTime!);
const duration = originalEnd.getTime() - originalStart.getTime();
return new Date(newStart.getTime() + duration).toISOString();
}
/**
* Updates recurrence rule with UNTIL clause
*/
updateRecurrenceWithUntil(recurrence: string[], untilDate: string): string[] {
if (!recurrence || recurrence.length === 0) {
throw new Error('No recurrence rule found');
}
const updatedRecurrence: string[] = [];
let foundRRule = false;
for (const rule of recurrence) {
if (rule.startsWith('RRULE:')) {
foundRRule = true;
const updatedRule = rule
.replace(/;UNTIL=\d{8}T\d{6}Z/g, '') // Remove existing UNTIL
.replace(/;COUNT=\d+/g, '') // Remove COUNT if present
+ `;UNTIL=${untilDate}`;
updatedRecurrence.push(updatedRule);
} else {
// Preserve EXDATE, RDATE, and other rules as-is
updatedRecurrence.push(rule);
}
}
if (!foundRRule) {
throw new Error('No RRULE found in recurrence rules');
}
return updatedRecurrence;
}
/**
* Cleans event fields for new event creation
*/
cleanEventForDuplication(event: calendar_v3.Schema$Event): calendar_v3.Schema$Event {
const cleanedEvent = { ...event };
// Remove fields that shouldn't be duplicated
delete cleanedEvent.id;
delete cleanedEvent.etag;
delete cleanedEvent.iCalUID;
delete cleanedEvent.created;
delete cleanedEvent.updated;
delete cleanedEvent.htmlLink;
delete cleanedEvent.hangoutLink;
return cleanedEvent;
}
/**
* Builds request body for event updates
*/
buildUpdateRequestBody(args: any, defaultTimeZone?: string): calendar_v3.Schema$Event {
const requestBody: calendar_v3.Schema$Event = {};
if (args.summary !== undefined && args.summary !== null) requestBody.summary = args.summary;
if (args.description !== undefined && args.description !== null) requestBody.description = args.description;
if (args.location !== undefined && args.location !== null) requestBody.location = args.location;
if (args.colorId !== undefined && args.colorId !== null) requestBody.colorId = args.colorId;
if (args.attendees !== undefined && args.attendees !== null) requestBody.attendees = args.attendees;
if (args.reminders !== undefined && args.reminders !== null) requestBody.reminders = args.reminders;
if (args.recurrence !== undefined && args.recurrence !== null) requestBody.recurrence = args.recurrence;
if (args.conferenceData !== undefined && args.conferenceData !== null) requestBody.conferenceData = args.conferenceData;
if (args.transparency !== undefined && args.transparency !== null) requestBody.transparency = args.transparency;
if (args.visibility !== undefined && args.visibility !== null) requestBody.visibility = args.visibility;
if (args.guestsCanInviteOthers !== undefined && args.guestsCanInviteOthers !== null) requestBody.guestsCanInviteOthers = args.guestsCanInviteOthers;
if (args.guestsCanModify !== undefined && args.guestsCanModify !== null) requestBody.guestsCanModify = args.guestsCanModify;
if (args.guestsCanSeeOtherGuests !== undefined && args.guestsCanSeeOtherGuests !== null) requestBody.guestsCanSeeOtherGuests = args.guestsCanSeeOtherGuests;
if (args.anyoneCanAddSelf !== undefined && args.anyoneCanAddSelf !== null) requestBody.anyoneCanAddSelf = args.anyoneCanAddSelf;
if (args.extendedProperties !== undefined && args.extendedProperties !== null) requestBody.extendedProperties = args.extendedProperties;
if (args.attachments !== undefined && args.attachments !== null) requestBody.attachments = args.attachments;
// Handle time changes - use createTimeObject to support both timed and all-day events
const effectiveTimeZone = args.timeZone || defaultTimeZone;
if (args.start !== undefined && args.start !== null) {
const timeObj = createTimeObject(args.start, effectiveTimeZone);
// When converting between formats, explicitly nullify the opposite field
// This is required by Google Calendar API to successfully convert between timed and all-day events
if (timeObj.date !== undefined) {
// All-day event: set date and nullify dateTime
requestBody.start = { date: timeObj.date, dateTime: null };
} else {
// Timed event: set dateTime/timeZone and nullify date
requestBody.start = { dateTime: timeObj.dateTime, timeZone: timeObj.timeZone, date: null };
}
}
if (args.end !== undefined && args.end !== null) {
const timeObj = createTimeObject(args.end, effectiveTimeZone);
// When converting between formats, explicitly nullify the opposite field
if (timeObj.date !== undefined) {
// All-day event: set date and nullify dateTime
requestBody.end = { date: timeObj.date, dateTime: null };
} else {
// Timed event: set dateTime/timeZone and nullify date
requestBody.end = { dateTime: timeObj.dateTime, timeZone: timeObj.timeZone, date: null };
}
}
return requestBody;
}
}
/**
* Custom error class for recurring event errors
*/
export class RecurringEventError extends Error {
public code: string;
constructor(message: string, code: string) {
super(message);
this.name = 'RecurringEventError';
this.code = code;
}
}
export const RECURRING_EVENT_ERRORS = {
INVALID_SCOPE: 'INVALID_MODIFICATION_SCOPE',
MISSING_ORIGINAL_TIME: 'MISSING_ORIGINAL_START_TIME',
MISSING_FUTURE_DATE: 'MISSING_FUTURE_START_DATE',
PAST_FUTURE_DATE: 'FUTURE_DATE_IN_PAST',
NON_RECURRING_SCOPE: 'SCOPE_NOT_APPLICABLE_TO_SINGLE_EVENT'
};
```
--------------------------------------------------------------------------------
/src/handlers/core/GetCurrentTimeHandler.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { OAuth2Client } from "google-auth-library";
import { BaseToolHandler } from "./BaseToolHandler.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { GetCurrentTimeInput } from "../../tools/registry.js";
import { createStructuredResponse } from "../../utils/response-builder.js";
import { GetCurrentTimeResponse } from "../../types/structured-responses.js";
export class GetCurrentTimeHandler extends BaseToolHandler {
async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
// Validate arguments using schema
const validArgs = args as GetCurrentTimeInput;
const now = new Date();
// If no timezone provided, use the primary Google Calendar's default timezone
const requestedTimeZone = validArgs.timeZone;
let timezone: string;
if (requestedTimeZone) {
// Validate the timezone
if (!this.isValidTimeZone(requestedTimeZone)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid timezone: ${requestedTimeZone}. Use IANA timezone format like 'America/Los_Angeles' or 'UTC'.`
);
}
timezone = requestedTimeZone;
} else {
// No timezone requested - fetch the primary calendar's timezone
// If fetching fails (e.g., auth/network), fall back to system timezone
try {
timezone = await this.getCalendarTimezone(oauth2Client, 'primary');
// If we got UTC back, it might be a fallback, try to detect if it's actually the system timezone
if (timezone === 'UTC') {
const systemTz = this.getSystemTimeZone();
if (systemTz !== 'UTC') {
// Likely failed to get calendar timezone
timezone = systemTz;
}
}
} catch (error) {
// This shouldn't happen with current implementation, but handle it
timezone = this.getSystemTimeZone();
}
}
const response: GetCurrentTimeResponse = {
currentTime: now.toISOString(),
timezone: timezone,
offset: this.getTimezoneOffset(now, timezone),
isDST: this.isDaylightSavingTime(now, timezone)
};
return createStructuredResponse(response);
}
private getSystemTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC'; // Fallback to UTC if system timezone detection fails
}
}
private isValidTimeZone(timeZone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone });
return true;
} catch {
return false;
}
}
private formatDateInTimeZone(date: Date, timeZone: string): string {
const offset = this.getTimezoneOffset(date, timeZone);
// Remove milliseconds from ISO string for proper RFC3339 format
const isoString = date.toISOString().replace(/\.\d{3}Z$/, '');
return isoString + offset;
}
private formatHumanReadable(date: Date, timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timeZone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'long'
});
return formatter.format(date);
}
private getTimezoneOffset(_date: Date, timeZone: string): string {
try {
const offsetMinutes = this.getTimezoneOffsetMinutes(timeZone);
if (offsetMinutes === 0) {
return 'Z';
}
const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60);
const offsetMins = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? '+' : '-';
return `${sign}${offsetHours.toString().padStart(2, '0')}:${offsetMins.toString().padStart(2, '0')}`;
} catch {
return 'Z'; // Fallback to UTC if offset calculation fails
}
}
private getTimezoneOffsetMinutes(timeZone: string): number {
// Use the timezone offset from a date's time representation in different zones
const date = new Date();
// Get local time for the target timezone
const targetTimeString = new Intl.DateTimeFormat('sv-SE', {
timeZone: timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
// Get UTC time string
const utcTimeString = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
// Parse both times and calculate difference
const targetTime = new Date(targetTimeString.replace(' ', 'T') + 'Z').getTime();
const utcTimeParsed = new Date(utcTimeString.replace(' ', 'T') + 'Z').getTime();
return (targetTime - utcTimeParsed) / (1000 * 60);
}
private isDaylightSavingTime(date: Date, timeZone: string): boolean {
try {
// Get offset for the given date
const currentOffset = this.getTimezoneOffsetForDate(date, timeZone);
// Get offset for January 1st (typically standard time)
const january = new Date(date.getFullYear(), 0, 1);
const januaryOffset = this.getTimezoneOffsetForDate(january, timeZone);
// Get offset for July 1st (typically daylight saving time if applicable)
const july = new Date(date.getFullYear(), 6, 1);
const julyOffset = this.getTimezoneOffsetForDate(july, timeZone);
// If January and July have different offsets, DST is observed
// Current date is in DST if its offset matches the smaller offset (more negative/less positive)
if (januaryOffset !== julyOffset) {
const dstOffset = Math.min(januaryOffset, julyOffset);
return currentOffset === dstOffset;
}
return false;
} catch {
return false;
}
}
private getTimezoneOffsetForDate(date: Date, timeZone: string): number {
// Get local time for the target timezone
const targetTimeString = new Intl.DateTimeFormat('sv-SE', {
timeZone: timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
// Get UTC time string
const utcTimeString = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
// Parse both times and calculate difference in minutes
const targetTime = new Date(targetTimeString.replace(' ', 'T') + 'Z').getTime();
const utcTimeParsed = new Date(utcTimeString.replace(' ', 'T') + 'Z').getTime();
return (targetTime - utcTimeParsed) / (1000 * 60);
}
}
```
--------------------------------------------------------------------------------
/src/handlers/core/BatchRequestHandler.ts:
--------------------------------------------------------------------------------
```typescript
import { OAuth2Client } from "google-auth-library";
export interface BatchRequest {
method: string;
path: string;
headers?: Record<string, string>;
body?: any;
}
export interface BatchResponse {
statusCode: number;
headers: Record<string, string>;
body: any;
error?: any;
}
export interface BatchError {
calendarId?: string;
statusCode: number;
message: string;
details?: any;
}
export class BatchRequestError extends Error {
constructor(
message: string,
public errors: BatchError[],
public partial: boolean = false
) {
super(message);
this.name = 'BatchRequestError';
}
}
export class BatchRequestHandler {
private readonly batchEndpoint = "https://www.googleapis.com/batch/calendar/v3";
private readonly boundary: string;
private readonly maxRetries = 3;
private readonly baseDelay = 1000; // 1 second
constructor(private auth: OAuth2Client) {
this.boundary = "batch_boundary_" + Date.now();
}
async executeBatch(requests: BatchRequest[]): Promise<BatchResponse[]> {
if (requests.length === 0) {
return [];
}
if (requests.length > 50) {
throw new Error('Batch requests cannot exceed 50 requests per batch');
}
return this.executeBatchWithRetry(requests, 0);
}
private async executeBatchWithRetry(requests: BatchRequest[], attempt: number): Promise<BatchResponse[]> {
try {
const batchBody = this.createBatchBody(requests);
const token = await this.auth.getAccessToken();
const response = await fetch(this.batchEndpoint, {
method: "POST",
headers: {
"Authorization": `Bearer ${token.token}`,
"Content-Type": `multipart/mixed; boundary=${this.boundary}`
},
body: batchBody
});
const responseText = await response.text();
// Handle rate limiting with retry
if (response.status === 429 && attempt < this.maxRetries) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : this.baseDelay * Math.pow(2, attempt);
process.stderr.write(`Rate limited, retrying after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})\n`);
await this.sleep(delay);
return this.executeBatchWithRetry(requests, attempt + 1);
}
if (!response.ok) {
throw new BatchRequestError(
`Batch request failed: ${response.status} ${response.statusText}`,
[{
statusCode: response.status,
message: `HTTP ${response.status}: ${response.statusText}`,
details: responseText
}]
);
}
return this.parseBatchResponse(responseText);
} catch (error) {
if (error instanceof BatchRequestError) {
throw error;
}
// Retry on network errors
if (attempt < this.maxRetries && this.isRetryableError(error)) {
const delay = this.baseDelay * Math.pow(2, attempt);
process.stderr.write(`Network error, retrying after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries}): ${error instanceof Error ? error.message : 'Unknown error'}\n`);
await this.sleep(delay);
return this.executeBatchWithRetry(requests, attempt + 1);
}
// Handle network or auth errors
throw new BatchRequestError(
`Failed to execute batch request: ${error instanceof Error ? error.message : 'Unknown error'}`,
[{
statusCode: 0,
message: error instanceof Error ? error.message : 'Unknown error',
details: error
}]
);
}
}
private isRetryableError(error: any): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return message.includes('network') ||
message.includes('timeout') ||
message.includes('econnreset') ||
message.includes('enotfound');
}
return false;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private createBatchBody(requests: BatchRequest[]): string {
return requests.map((req, index) => {
const parts = [
`--${this.boundary}`,
`Content-Type: application/http`,
`Content-ID: <item${index + 1}>`,
"",
`${req.method} ${req.path} HTTP/1.1`
];
if (req.headers) {
Object.entries(req.headers).forEach(([key, value]) => {
parts.push(`${key}: ${value}`);
});
}
if (req.body) {
parts.push("Content-Type: application/json");
parts.push("");
parts.push(JSON.stringify(req.body));
}
return parts.join("\r\n");
}).join("\r\n\r\n") + `\r\n--${this.boundary}--`;
}
private parseBatchResponse(responseText: string): BatchResponse[] {
// First, try to find boundary from Content-Type header in the response
// Google's responses typically have boundary in the first few lines
const lines = responseText.split(/\r?\n/);
let boundary = null;
// Look for Content-Type header with boundary in the first few lines
for (let i = 0; i < Math.min(10, lines.length); i++) {
const line = lines[i];
if (line.toLowerCase().includes('content-type:') && line.includes('boundary=')) {
const boundaryMatch = line.match(/boundary=([^\s\r\n;]+)/);
if (boundaryMatch) {
boundary = boundaryMatch[1];
break;
}
}
}
// If not found in headers, try to find boundary markers in the content
if (!boundary) {
const boundaryMatch = responseText.match(/--([a-zA-Z0-9_-]+)/);
if (boundaryMatch) {
boundary = boundaryMatch[1];
}
}
if (!boundary) {
throw new Error('Could not find boundary in batch response');
}
// Split by boundary markers
const parts = responseText.split(`--${boundary}`);
const responses: BatchResponse[] = [];
// Skip the first part (before the first boundary) and the last part (after final boundary with --)
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
// Skip empty parts or the final boundary marker
if (part.trim() === '' || part.trim() === '--' || part.trim().startsWith('--')) continue;
const response = this.parseResponsePart(part);
if (response) {
responses.push(response);
}
}
return responses;
}
private parseResponsePart(part: string): BatchResponse | null {
// Handle both \r\n and \n line endings
const lines = part.split(/\r?\n/);
// Find the HTTP response line (look for "HTTP/1.1")
let httpLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('HTTP/1.1')) {
httpLineIndex = i;
break;
}
}
if (httpLineIndex === -1) return null;
// Parse status code from HTTP response line
const httpLine = lines[httpLineIndex];
const statusMatch = httpLine.match(/HTTP\/1\.1 (\d+)/);
if (!statusMatch) return null;
const statusCode = parseInt(statusMatch[1]);
// Parse response headers (start after HTTP line, stop at empty line)
const headers: Record<string, string> = {};
let bodyStartIndex = httpLineIndex + 1;
for (let i = httpLineIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') {
bodyStartIndex = i + 1;
break;
}
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headers[key] = value;
}
}
// Parse body - everything after the empty line following headers
let body: any = null;
if (bodyStartIndex < lines.length) {
// Collect all body lines, filtering out empty lines at the end
const bodyLines = [];
for (let i = bodyStartIndex; i < lines.length; i++) {
bodyLines.push(lines[i]);
}
// Remove trailing empty lines
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === '') {
bodyLines.pop();
}
if (bodyLines.length > 0) {
const bodyText = bodyLines.join('\n');
if (bodyText.trim()) {
try {
body = JSON.parse(bodyText);
} catch {
// If JSON parsing fails, return the raw text
body = bodyText;
}
}
}
}
return {
statusCode,
headers,
body
};
}
}
```
--------------------------------------------------------------------------------
/src/tests/unit/services/conflict-detection/EventSimilarityChecker.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from 'vitest';
import { EventSimilarityChecker } from '../../../../services/conflict-detection/EventSimilarityChecker.js';
import { calendar_v3 } from 'googleapis';
describe('EventSimilarityChecker', () => {
const checker = new EventSimilarityChecker();
describe('checkSimilarity', () => {
it('should return 0.95 for identical events', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
location: 'Conference Room A',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2 = { ...event1 };
const similarity = checker.checkSimilarity(event1, event2);
expect(similarity).toBe(0.95); // Our simplified algorithm returns 0.95 for exact matches
});
it('should detect high similarity for events with same title and time', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
location: 'Conference Room A',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
location: 'Conference Room B', // Different location
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const similarity = checker.checkSimilarity(event1, event2);
expect(similarity).toBeGreaterThan(0.8);
});
it('should detect moderate similarity for events with similar titles', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2: calendar_v3.Schema$Event = {
summary: 'Team Meeting Discussion',
start: { dateTime: '2024-01-01T14:00:00' }, // Different time
end: { dateTime: '2024-01-01T15:00:00' }
};
const similarity = checker.checkSimilarity(event1, event2);
expect(similarity).toBe(0.3); // Similar titles only = 0.3 in our simplified algorithm
});
it('should detect low similarity for completely different events', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
location: 'Conference Room A',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2: calendar_v3.Schema$Event = {
summary: 'Doctor Appointment',
location: 'Medical Center',
start: { dateTime: '2024-02-15T09:00:00' },
end: { dateTime: '2024-02-15T09:30:00' }
};
const similarity = checker.checkSimilarity(event1, event2);
expect(similarity).toBeLessThan(0.3);
});
it('should handle events with missing fields', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Meeting',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2: calendar_v3.Schema$Event = {
// No summary
location: 'Room 101',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const similarity = checker.checkSimilarity(event1, event2);
expect(similarity).toBeGreaterThan(0); // Time matches
expect(similarity).toBeLessThan(0.5); // But no title match
});
it('should handle all-day events', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Conference',
start: { date: '2024-01-01' },
end: { date: '2024-01-02' }
};
const event2: calendar_v3.Schema$Event = {
summary: 'Conference',
start: { date: '2024-01-01' },
end: { date: '2024-01-02' }
};
const similarity = checker.checkSimilarity(event1, event2);
expect(similarity).toBe(0.95); // Exact title + overlapping = 0.95
});
});
describe('all-day vs timed events', () => {
it('should not treat all-day event as duplicate of timed event with same title', () => {
const allDayEvent: calendar_v3.Schema$Event = {
summary: 'Conference',
start: { date: '2024-01-15' },
end: { date: '2024-01-16' }
};
const timedEvent: calendar_v3.Schema$Event = {
summary: 'Conference',
start: { dateTime: '2024-01-15T09:00:00' },
end: { dateTime: '2024-01-15T17:00:00' }
};
const similarity = checker.checkSimilarity(allDayEvent, timedEvent);
expect(similarity).toBeLessThanOrEqual(0.3);
expect(checker.isDuplicate(allDayEvent, timedEvent)).toBe(false);
});
it('should not treat timed event as duplicate of all-day event', () => {
const timedEvent: calendar_v3.Schema$Event = {
summary: 'Team Offsite',
location: 'Mountain View',
start: { dateTime: '2024-01-15T10:00:00' },
end: { dateTime: '2024-01-15T15:00:00' }
};
const allDayEvent: calendar_v3.Schema$Event = {
summary: 'Team Offsite',
location: 'Mountain View',
start: { date: '2024-01-15' },
end: { date: '2024-01-16' }
};
const similarity = checker.checkSimilarity(timedEvent, allDayEvent);
expect(similarity).toBeLessThanOrEqual(0.3);
expect(checker.isDuplicate(timedEvent, allDayEvent, 0.7)).toBe(false);
});
it('should still detect duplicates between two all-day events', () => {
const allDay1: calendar_v3.Schema$Event = {
summary: 'Company Holiday',
start: { date: '2024-07-04' },
end: { date: '2024-07-05' }
};
const allDay2: calendar_v3.Schema$Event = {
summary: 'Company Holiday',
start: { date: '2024-07-04' },
end: { date: '2024-07-05' }
};
const similarity = checker.checkSimilarity(allDay1, allDay2);
expect(similarity).toBe(0.95); // Exact title + overlapping = 0.95
expect(checker.isDuplicate(allDay1, allDay2)).toBe(true);
});
it('should still detect duplicates between two timed events', () => {
const timed1: calendar_v3.Schema$Event = {
summary: 'Sprint Planning',
start: { dateTime: '2024-01-15T10:00:00' },
end: { dateTime: '2024-01-15T12:00:00' }
};
const timed2: calendar_v3.Schema$Event = {
summary: 'Sprint Planning',
start: { dateTime: '2024-01-15T10:00:00' },
end: { dateTime: '2024-01-15T12:00:00' }
};
const similarity = checker.checkSimilarity(timed1, timed2);
expect(similarity).toBe(0.95); // Exact title + overlapping = 0.95
expect(checker.isDuplicate(timed1, timed2)).toBe(true);
});
it('should handle common patterns like OOO/vacation', () => {
const allDayOOO: calendar_v3.Schema$Event = {
summary: 'John OOO',
start: { date: '2024-01-15' },
end: { date: '2024-01-16' }
};
const timedMeeting: calendar_v3.Schema$Event = {
summary: 'Meeting with John',
start: { dateTime: '2024-01-15T14:00:00' },
end: { dateTime: '2024-01-15T15:00:00' }
};
const similarity = checker.checkSimilarity(allDayOOO, timedMeeting);
expect(similarity).toBeLessThan(0.3);
expect(checker.isDuplicate(allDayOOO, timedMeeting)).toBe(false);
});
});
describe('isDuplicate', () => {
it('should identify duplicates above threshold', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
expect(checker.isDuplicate(event1, event2)).toBe(true); // 0.95 >= 0.7 default threshold
expect(checker.isDuplicate(event1, event2, 0.9)).toBe(true); // 0.95 >= 0.9
expect(checker.isDuplicate(event1, event2, 0.96)).toBe(false); // 0.95 < 0.96
});
it('should not identify non-duplicates as duplicates', () => {
const event1: calendar_v3.Schema$Event = {
summary: 'Team Meeting',
start: { dateTime: '2024-01-01T10:00:00' },
end: { dateTime: '2024-01-01T11:00:00' }
};
const event2: calendar_v3.Schema$Event = {
summary: 'Different Meeting',
start: { dateTime: '2024-01-02T14:00:00' },
end: { dateTime: '2024-01-02T15:00:00' }
};
expect(checker.isDuplicate(event1, event2)).toBe(false);
expect(checker.isDuplicate(event1, event2, 0.5)).toBe(false);
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/schemas/tool-registration.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ToolRegistry, ToolSchemas } from '../../../tools/registry.js';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
/**
* Tool Registration Tests
*
* These tests validate that all tools are properly registered with the MCP server
* and that their schemas are correctly extracted, especially for complex schemas
* that use .refine() methods (like update-event).
*/
describe('Tool Registration', () => {
let mockServer: McpServer;
let registeredTools: Array<{ name: string; description: string; inputSchema: any }>;
beforeEach(() => {
mockServer = new McpServer({ name: 'test', version: '1.0.0' });
registeredTools = [];
// Mock the registerTool method to capture registered tools
mockServer.registerTool = vi.fn((name: string, definition: any, _handler: any) => {
registeredTools.push({
name,
description: definition.description,
inputSchema: definition.inputSchema
});
// Return a mock RegisteredTool
return { name, description: definition.description } as any;
});
});
it('should register all tools successfully without errors', async () => {
// This should not throw any errors
await expect(
ToolRegistry.registerAll(mockServer, async () => ({ content: [] }))
).resolves.not.toThrow();
});
it('should register the correct number of tools', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
const expectedToolCount = Object.keys(ToolSchemas).length;
expect(registeredTools).toHaveLength(expectedToolCount);
});
it('should register all expected tool names', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
const expectedTools = Object.keys(ToolSchemas);
const registeredToolNames = registeredTools.map(t => t.name);
for (const expectedTool of expectedTools) {
expect(registeredToolNames).toContain(expectedTool);
}
});
it('should have valid input schemas for all tools', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
for (const tool of registeredTools) {
expect(tool.inputSchema).toBeDefined();
expect(typeof tool.inputSchema).toBe('object');
// The inputSchema should be either a Zod shape object or have been converted properly
// For tools with complex schemas, we should still get a valid object
if (tool.name === 'update-event') {
// This is the key test - update-event should not have an empty schema
expect(Object.keys(tool.inputSchema).length).toBeGreaterThan(0);
}
}
});
it('should properly extract schema for update-event tool with .refine() methods', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
const updateEventTool = registeredTools.find(t => t.name === 'update-event');
expect(updateEventTool).toBeDefined();
const schema = updateEventTool!.inputSchema;
expect(schema).toBeDefined();
// The key test: schema should not be empty for update-event
expect(Object.keys(schema).length).toBeGreaterThan(0);
// Check for key update-event specific properties in the Zod shape
expect(schema).toHaveProperty('calendarId');
expect(schema).toHaveProperty('eventId');
expect(schema).toHaveProperty('modificationScope');
expect(schema).toHaveProperty('originalStartTime');
expect(schema).toHaveProperty('futureStartDate');
});
it('should compare update-event with create-event to ensure both have proper schemas', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
const createEventTool = registeredTools.find(t => t.name === 'create-event');
const updateEventTool = registeredTools.find(t => t.name === 'update-event');
expect(createEventTool).toBeDefined();
expect(updateEventTool).toBeDefined();
// Both should have similar basic structure
const createSchema = createEventTool!.inputSchema;
const updateSchema = updateEventTool!.inputSchema;
// Both should have non-empty schemas
expect(Object.keys(createSchema).length).toBeGreaterThan(0);
expect(Object.keys(updateSchema).length).toBeGreaterThan(0);
// Both should have calendarId in their Zod shapes
expect(createSchema).toHaveProperty('calendarId');
expect(updateSchema).toHaveProperty('calendarId');
// Update should have additional properties that create doesn't need
expect(updateSchema).toHaveProperty('eventId');
expect(updateSchema).toHaveProperty('modificationScope');
// Create should have required properties that update makes optional
expect(createSchema).toHaveProperty('summary');
expect(createSchema).toHaveProperty('start');
expect(createSchema).toHaveProperty('end');
});
it('should handle all complex schemas with refinements properly', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
// Tools that use .refine() methods
const toolsWithRefinements = ['update-event'];
for (const toolName of toolsWithRefinements) {
const tool = registeredTools.find(t => t.name === toolName);
expect(tool).toBeDefined();
const schema = tool!.inputSchema;
expect(schema).toBeDefined();
// Should not be empty - this was the original bug
expect(Object.keys(schema).length).toBeGreaterThan(0);
}
});
it('should validate that tools can be retrieved via getToolsWithSchemas()', () => {
const tools = ToolRegistry.getToolsWithSchemas();
expect(tools).toBeDefined();
expect(Array.isArray(tools)).toBe(true);
expect(tools.length).toBeGreaterThan(0);
// Check that update-event is present and has a valid schema
const updateEventTool = tools.find(t => t.name === 'update-event');
expect(updateEventTool).toBeDefined();
expect(updateEventTool!.inputSchema).toBeDefined();
// The inputSchema should be a valid JSON Schema object
expect(typeof updateEventTool!.inputSchema).toBe('object');
expect((updateEventTool!.inputSchema as any).type).toBe('object');
});
it('should ensure all datetime fields have proper validation', async () => {
await ToolRegistry.registerAll(mockServer, async () => ({ content: [] }));
const toolsWithDatetime = ['create-event', 'update-event', 'list-events', 'search-events'];
for (const toolName of toolsWithDatetime) {
const tool = registeredTools.find(t => t.name === toolName);
expect(tool).toBeDefined();
const schema = tool!.inputSchema;
expect(Object.keys(schema).length).toBeGreaterThan(0);
// Just verify the schema exists and is not empty for datetime tools
// The actual field validation is tested elsewhere
if (toolName === 'update-event') {
expect(schema).toHaveProperty('start');
expect(schema).toHaveProperty('end');
}
}
});
it('should catch schema extraction issues early', async () => {
// Test the schema extraction method directly
const updateEventSchema = ToolSchemas['update-event'];
expect(updateEventSchema).toBeDefined();
// This should not throw an error
const extractedShape = (ToolRegistry as any).extractSchemaShape(updateEventSchema);
expect(extractedShape).toBeDefined();
expect(typeof extractedShape).toBe('object');
// Should have the expected properties
expect(extractedShape).toHaveProperty('calendarId');
expect(extractedShape).toHaveProperty('eventId');
expect(extractedShape).toHaveProperty('modificationScope');
});
});
/**
* Schema Extraction Edge Cases
*
* Tests to ensure the extractSchemaShape method handles various Zod schema types
*/
describe('Schema Extraction Edge Cases', () => {
it('should handle regular ZodObject schemas', () => {
const simpleSchema = ToolSchemas['list-calendars'];
const extractedShape = (ToolRegistry as any).extractSchemaShape(simpleSchema);
expect(extractedShape).toBeDefined();
});
it('should handle ZodEffects (refined) schemas', () => {
const refinedSchema = ToolSchemas['update-event'];
const extractedShape = (ToolRegistry as any).extractSchemaShape(refinedSchema);
expect(extractedShape).toBeDefined();
expect(typeof extractedShape).toBe('object');
});
it('should handle nested schema structures', () => {
const complexSchema = ToolSchemas['create-event'];
const extractedShape = (ToolRegistry as any).extractSchemaShape(complexSchema);
expect(extractedShape).toBeDefined();
expect(typeof extractedShape).toBe('object');
});
});
```
--------------------------------------------------------------------------------
/src/handlers/core/ListEventsHandler.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { OAuth2Client } from "google-auth-library";
import { BaseToolHandler } from "./BaseToolHandler.js";
import { calendar_v3 } from 'googleapis';
import { BatchRequestHandler } from "./BatchRequestHandler.js";
import { convertToRFC3339 } from "../utils/datetime.js";
import { buildListFieldMask } from "../../utils/field-mask-builder.js";
import { createStructuredResponse } from "../../utils/response-builder.js";
import { ListEventsResponse, StructuredEvent, convertGoogleEventToStructured } from "../../types/structured-responses.js";
// Extended event type to include calendar ID for tracking source
interface ExtendedEvent extends calendar_v3.Schema$Event {
calendarId: string;
}
interface ListEventsArgs {
calendarId: string | string[];
timeMin?: string;
timeMax?: string;
timeZone?: string;
fields?: string[];
privateExtendedProperty?: string[];
sharedExtendedProperty?: string[];
}
export class ListEventsHandler extends BaseToolHandler {
async runTool(args: ListEventsArgs, oauth2Client: OAuth2Client): Promise<CallToolResult> {
// MCP SDK has already validated the arguments against the tool schema
const validArgs = args;
// Normalize calendarId to always be an array for consistent processing
// The Zod schema transform has already handled JSON string parsing if needed
const calendarNamesOrIds = Array.isArray(validArgs.calendarId)
? validArgs.calendarId
: [validArgs.calendarId];
// Resolve calendar names to IDs (if any names were provided)
const calendarIds = await this.resolveCalendarIds(oauth2Client, calendarNamesOrIds);
const allEvents = await this.fetchEvents(oauth2Client, calendarIds, {
timeMin: validArgs.timeMin,
timeMax: validArgs.timeMax,
timeZone: validArgs.timeZone,
fields: validArgs.fields,
privateExtendedProperty: validArgs.privateExtendedProperty,
sharedExtendedProperty: validArgs.sharedExtendedProperty
});
// Convert extended events to structured format
const structuredEvents: StructuredEvent[] = allEvents.map(event =>
convertGoogleEventToStructured(event, event.calendarId)
);
const response: ListEventsResponse = {
events: structuredEvents,
totalCount: allEvents.length,
calendars: calendarIds.length > 1 ? calendarIds : undefined
};
return createStructuredResponse(response);
}
private async fetchEvents(
client: OAuth2Client,
calendarIds: string[],
options: { timeMin?: string; timeMax?: string; timeZone?: string; fields?: string[]; privateExtendedProperty?: string[]; sharedExtendedProperty?: string[] }
): Promise<ExtendedEvent[]> {
if (calendarIds.length === 1) {
return this.fetchSingleCalendarEvents(client, calendarIds[0], options);
}
return this.fetchMultipleCalendarEvents(client, calendarIds, options);
}
private async fetchSingleCalendarEvents(
client: OAuth2Client,
calendarId: string,
options: { timeMin?: string; timeMax?: string; timeZone?: string; fields?: string[]; privateExtendedProperty?: string[]; sharedExtendedProperty?: string[] }
): Promise<ExtendedEvent[]> {
try {
const calendar = this.getCalendar(client);
// Determine timezone with correct precedence:
// 1. Explicit timeZone parameter (highest priority)
// 2. Calendar's default timezone (fallback)
// Note: convertToRFC3339 will still respect timezone in datetime string as ultimate override
let timeMin = options.timeMin;
let timeMax = options.timeMax;
if (timeMin || timeMax) {
const timezone = options.timeZone || await this.getCalendarTimezone(client, calendarId);
timeMin = timeMin ? convertToRFC3339(timeMin, timezone) : undefined;
timeMax = timeMax ? convertToRFC3339(timeMax, timezone) : undefined;
}
const fieldMask = buildListFieldMask(options.fields);
const response = await calendar.events.list({
calendarId,
timeMin,
timeMax,
singleEvents: true,
orderBy: 'startTime',
...(fieldMask && { fields: fieldMask }),
...(options.privateExtendedProperty && { privateExtendedProperty: options.privateExtendedProperty as any }),
...(options.sharedExtendedProperty && { sharedExtendedProperty: options.sharedExtendedProperty as any })
});
// Add calendarId to events for consistent interface
return (response.data.items || []).map(event => ({
...event,
calendarId
}));
} catch (error) {
throw this.handleGoogleApiError(error);
}
}
private async fetchMultipleCalendarEvents(
client: OAuth2Client,
calendarIds: string[],
options: { timeMin?: string; timeMax?: string; timeZone?: string; fields?: string[]; privateExtendedProperty?: string[]; sharedExtendedProperty?: string[] }
): Promise<ExtendedEvent[]> {
const batchHandler = new BatchRequestHandler(client);
const requests = await Promise.all(calendarIds.map(async (calendarId) => ({
method: "GET" as const,
path: await this.buildEventsPath(client, calendarId, options)
})));
const responses = await batchHandler.executeBatch(requests);
const { events, errors } = this.processBatchResponses(responses, calendarIds);
if (errors.length > 0) {
process.stderr.write(`Some calendars had errors: ${errors.map(e => `${e.calendarId}: ${e.error}`).join(', ')}\n`);
}
return this.sortEventsByStartTime(events);
}
private async buildEventsPath(client: OAuth2Client, calendarId: string, options: { timeMin?: string; timeMax?: string; timeZone?: string; fields?: string[]; privateExtendedProperty?: string[]; sharedExtendedProperty?: string[] }): Promise<string> {
// Determine timezone with correct precedence:
// 1. Explicit timeZone parameter (highest priority)
// 2. Calendar's default timezone (fallback)
// Note: convertToRFC3339 will still respect timezone in datetime string as ultimate override
let timeMin = options.timeMin;
let timeMax = options.timeMax;
if (timeMin || timeMax) {
const timezone = options.timeZone || await this.getCalendarTimezone(client, calendarId);
timeMin = timeMin ? convertToRFC3339(timeMin, timezone) : undefined;
timeMax = timeMax ? convertToRFC3339(timeMax, timezone) : undefined;
}
const fieldMask = buildListFieldMask(options.fields);
const params = new URLSearchParams({
singleEvents: "true",
orderBy: "startTime",
});
if (timeMin) params.set('timeMin', timeMin);
if (timeMax) params.set('timeMax', timeMax);
if (fieldMask) params.set('fields', fieldMask);
if (options.privateExtendedProperty) {
for (const kv of options.privateExtendedProperty) params.append('privateExtendedProperty', kv);
}
if (options.sharedExtendedProperty) {
for (const kv of options.sharedExtendedProperty) params.append('sharedExtendedProperty', kv);
}
return `/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params.toString()}`;
}
private processBatchResponses(
responses: any[],
calendarIds: string[]
): { events: ExtendedEvent[]; errors: Array<{ calendarId: string; error: string }> } {
const events: ExtendedEvent[] = [];
const errors: Array<{ calendarId: string; error: string }> = [];
responses.forEach((response, index) => {
const calendarId = calendarIds[index];
if (response.statusCode === 200 && response.body?.items) {
const calendarEvents: ExtendedEvent[] = response.body.items.map((event: any) => ({
...event,
calendarId
}));
events.push(...calendarEvents);
} else {
const errorMessage = response.body?.error?.message ||
response.body?.message ||
`HTTP ${response.statusCode}`;
errors.push({ calendarId, error: errorMessage });
}
});
return { events, errors };
}
private sortEventsByStartTime(events: ExtendedEvent[]): ExtendedEvent[] {
return events.sort((a, b) => {
const aStart = a.start?.dateTime || a.start?.date || "";
const bStart = b.start?.dateTime || b.start?.date || "";
return aStart.localeCompare(bStart);
});
}
private groupEventsByCalendar(events: ExtendedEvent[]): Record<string, ExtendedEvent[]> {
return events.reduce((acc, event) => {
const calId = event.calendarId;
if (!acc[calId]) acc[calId] = [];
acc[calId].push(event);
return acc;
}, {} as Record<string, ExtendedEvent[]>);
}
}
```
--------------------------------------------------------------------------------
/scripts/account-manager.js:
--------------------------------------------------------------------------------
```javascript
#!/usr/bin/env node
/**
* Account Manager Script
*
* This script helps manage OAuth tokens for multiple Google accounts:
* - Normal account: For regular operations
* - Test account: For integration testing
*
* Usage:
* node scripts/account-manager.js list # List available accounts
* node scripts/account-manager.js auth normal # Authenticate normal account
* node scripts/account-manager.js auth test # Authenticate test account
* node scripts/account-manager.js status # Show current account status
* node scripts/account-manager.js clear normal # Clear normal account tokens
* node scripts/account-manager.js clear test # Clear test account tokens
* node scripts/account-manager.js test # Run tests with test account
*/
import { spawn } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..');
const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
function colorize(color, text) {
return `${COLORS[color]}${text}${COLORS.reset}`;
}
function log(message, color = 'reset') {
console.log(colorize(color, message));
}
function error(message) {
console.error(colorize('red', `❌ ${message}`));
}
function success(message) {
console.log(colorize('green', `✅ ${message}`));
}
function info(message) {
console.log(colorize('blue', `ℹ️ ${message}`));
}
function warning(message) {
console.log(colorize('yellow', `⚠️ ${message}`));
}
async function runCommand(command, args, env = {}) {
return new Promise((resolve, reject) => {
const fullEnv = { ...process.env, ...env };
const proc = spawn(command, args, {
stdio: 'inherit',
env: fullEnv,
cwd: projectRoot
});
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with exit code ${code}`));
}
});
proc.on('error', reject);
});
}
// Import shared path utilities
import { getSecureTokenPath } from '../src/auth/paths.js';
async function loadTokens() {
const tokenPath = getSecureTokenPath();
try {
const content = await fs.readFile(tokenPath, 'utf-8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
return {};
}
throw error;
}
}
async function listAccounts() {
log('\n' + colorize('bright', '📋 Available Accounts:'));
try {
const tokens = await loadTokens();
// Check if this is the old single-account format
if (tokens.access_token || tokens.refresh_token) {
log(' ' + colorize('yellow', '⚠️ Old token format detected. Will be migrated on next auth.'));
const hasAccessToken = !!tokens.access_token;
const hasRefreshToken = !!tokens.refresh_token;
const isExpired = tokens.expiry_date ? Date.now() >= tokens.expiry_date : true;
const status = hasAccessToken && hasRefreshToken && !isExpired ?
colorize('green', '✓ Active') :
hasRefreshToken ?
colorize('yellow', '⟳ Needs Refresh') :
colorize('red', '✗ Invalid');
log(` ${colorize('cyan', 'normal'.padEnd(10))} ${status} (legacy format)`);
return;
}
// New multi-account format
const accounts = Object.keys(tokens);
if (accounts.length === 0) {
warning('No accounts found. Use "auth" command to authenticate.');
return;
}
for (const account of accounts) {
const tokenInfo = tokens[account];
const hasAccessToken = !!tokenInfo.access_token;
const hasRefreshToken = !!tokenInfo.refresh_token;
const isExpired = tokenInfo.expiry_date ? Date.now() >= tokenInfo.expiry_date : true;
const status = hasAccessToken && hasRefreshToken && !isExpired ?
colorize('green', '✓ Active') :
hasRefreshToken ?
colorize('yellow', '⟳ Needs Refresh') :
colorize('red', '✗ Invalid');
log(` ${colorize('cyan', account.padEnd(10))} ${status}`);
}
} catch (error) {
error(`Failed to load token information: ${error.message}`);
}
}
async function authenticateAccount(accountMode) {
if (!['normal', 'test'].includes(accountMode)) {
error('Account mode must be "normal" or "test"');
process.exit(1);
}
log(`\n🔐 Authenticating ${colorize('cyan', accountMode)} account...`);
try {
await runCommand('npm', ['run', 'auth'], {
GOOGLE_ACCOUNT_MODE: accountMode
});
success(`Successfully authenticated ${accountMode} account!`);
} catch (error) {
error(`Failed to authenticate ${accountMode} account: ${error.message}`);
process.exit(1);
}
}
async function showStatus() {
log('\n' + colorize('bright', '📊 Account Status:'));
const currentMode = process.env.GOOGLE_ACCOUNT_MODE || 'normal';
log(` Current Mode: ${colorize('cyan', currentMode)}`);
await listAccounts();
// Show environment variables relevant to testing
log('\n' + colorize('bright', '🧪 Test Configuration:'));
const testVars = [
'TEST_CALENDAR_ID',
'INVITEE_1',
'INVITEE_2',
'CLAUDE_API_KEY'
];
for (const varName of testVars) {
const value = process.env[varName];
if (value) {
const displayValue = varName === 'CLAUDE_API_KEY' ?
value.substring(0, 8) + '...' : value;
log(` ${varName.padEnd(20)}: ${colorize('green', displayValue)}`);
} else {
log(` ${varName.padEnd(20)}: ${colorize('red', 'Not set')}`);
}
}
}
async function clearAccount(accountMode) {
if (!['normal', 'test'].includes(accountMode)) {
error('Account mode must be "normal" or "test"');
process.exit(1);
}
log(`\n🗑️ Clearing ${colorize('cyan', accountMode)} account tokens...`);
try {
const tokens = await loadTokens();
if (!tokens[accountMode]) {
warning(`No tokens found for ${accountMode} account`);
return;
}
delete tokens[accountMode];
const tokenPath = getSecureTokenPath();
if (Object.keys(tokens).length === 0) {
await fs.unlink(tokenPath);
success('All tokens cleared, file deleted');
} else {
await fs.writeFile(tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
success(`Cleared tokens for ${accountMode} account`);
}
} catch (error) {
error(`Failed to clear ${accountMode} account: ${error.message}`);
process.exit(1);
}
}
async function runTests() {
log('\n🧪 Running integration tests with test account...');
try {
await runCommand('npm', ['test'], {
GOOGLE_ACCOUNT_MODE: 'test'
});
success('Tests completed successfully!');
} catch (error) {
error(`Tests failed: ${error.message}`);
process.exit(1);
}
}
function showUsage() {
log('\n' + colorize('bright', 'Google Calendar Account Manager'));
log('\nManage OAuth tokens for multiple Google accounts (normal & test)');
log('\n' + colorize('bright', 'Usage:'));
log(' node scripts/account-manager.js <command> [args]');
log('\n' + colorize('bright', 'Commands:'));
log(' list List available accounts and their status');
log(' auth <normal|test> Authenticate the specified account');
log(' status Show current account status and configuration');
log(' clear <normal|test> Clear tokens for the specified account');
log(' test Run integration tests with test account');
log(' help Show this help message');
log('\n' + colorize('bright', 'Examples:'));
log(' node scripts/account-manager.js auth test # Authenticate test account');
log(' node scripts/account-manager.js test # Run tests with test account');
log(' node scripts/account-manager.js status # Check account status');
log('\n' + colorize('bright', 'Environment Variables:'));
log(' GOOGLE_ACCOUNT_MODE Set to "test" or "normal" (default: normal)');
log(' TEST_CALENDAR_ID Calendar ID to use for testing');
log(' INVITEE_1, INVITEE_2 Email addresses for testing invitations');
log(' CLAUDE_API_KEY API key for Claude integration tests');
}
async function main() {
const command = process.argv[2];
const arg = process.argv[3];
switch (command) {
case 'list':
await listAccounts();
break;
case 'auth':
if (!arg) {
error('Please specify account mode: normal or test');
process.exit(1);
}
await authenticateAccount(arg);
break;
case 'status':
await showStatus();
break;
case 'clear':
if (!arg) {
error('Please specify account mode: normal or test');
process.exit(1);
}
await clearAccount(arg);
break;
case 'test':
await runTests();
break;
case 'help':
case '--help':
case '-h':
showUsage();
break;
default:
if (command) {
error(`Unknown command: ${command}`);
}
showUsage();
process.exit(1);
}
}
// Handle uncaught errors
process.on('unhandledRejection', (reason, promise) => {
error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
process.exit(1);
});
process.on('uncaughtException', (error) => {
error(`Uncaught exception: ${error.message}`);
process.exit(1);
});
main().catch((error) => {
error(`Script failed: ${error.message}`);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/types/structured-responses.ts:
--------------------------------------------------------------------------------
```typescript
import { calendar_v3 } from 'googleapis';
/**
* Represents a date/time value in Google Calendar API format
*/
export interface DateTime {
dateTime?: string;
date?: string;
timeZone?: string;
}
/**
* Represents an event attendee with their response status and details
*/
export interface Attendee {
email: string;
displayName?: string;
responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted';
optional?: boolean;
organizer?: boolean;
self?: boolean;
resource?: boolean;
comment?: string;
additionalGuests?: number;
}
/**
* Conference/meeting information for an event (e.g., Google Meet, Zoom)
*/
export interface ConferenceData {
conferenceId?: string;
conferenceSolution?: {
key?: { type?: string };
name?: string;
iconUri?: string;
};
entryPoints?: Array<{
entryPointType?: string;
uri?: string;
label?: string;
pin?: string;
accessCode?: string;
meetingCode?: string;
passcode?: string;
password?: string;
}>;
createRequest?: {
requestId?: string;
conferenceSolutionKey?: { type?: string };
status?: { statusCode?: string };
};
parameters?: {
addOnParameters?: {
parameters?: Record<string, string>;
};
};
}
/**
* Custom key-value pairs for storing additional event metadata
*/
export interface ExtendedProperties {
private?: Record<string, string>;
shared?: Record<string, string>;
}
/**
* Event reminder configuration
*/
export interface Reminder {
method: 'email' | 'popup';
minutes: number;
}
/**
* Complete structured representation of a Google Calendar event
*/
export interface StructuredEvent {
id: string;
summary?: string;
description?: string;
location?: string;
start: DateTime;
end: DateTime;
status?: string;
htmlLink?: string;
created?: string;
updated?: string;
colorId?: string;
creator?: {
email?: string;
displayName?: string;
self?: boolean;
};
organizer?: {
email?: string;
displayName?: string;
self?: boolean;
};
attendees?: Attendee[];
recurrence?: string[];
recurringEventId?: string;
originalStartTime?: DateTime;
transparency?: 'opaque' | 'transparent';
visibility?: 'default' | 'public' | 'private' | 'confidential';
iCalUID?: string;
sequence?: number;
reminders?: {
useDefault?: boolean;
overrides?: Reminder[];
};
source?: {
url?: string;
title?: string;
};
attachments?: Array<{
fileUrl?: string;
title?: string;
mimeType?: string;
iconLink?: string;
fileId?: string;
}>;
eventType?: 'default' | 'outOfOffice' | 'focusTime' | 'workingLocation';
conferenceData?: ConferenceData;
extendedProperties?: ExtendedProperties;
hangoutLink?: string;
anyoneCanAddSelf?: boolean;
guestsCanInviteOthers?: boolean;
guestsCanModify?: boolean;
guestsCanSeeOtherGuests?: boolean;
privateCopy?: boolean;
locked?: boolean;
calendarId?: string;
}
/**
* Information about a scheduling conflict with another event
*/
export interface ConflictInfo {
event: {
id: string;
title: string;
start: string;
end: string;
url?: string;
similarity?: number;
};
calendar: string;
overlap?: {
duration: string;
percentage: string;
};
suggestion?: string;
}
/**
* Information about a potential duplicate event
*/
export interface DuplicateInfo {
event: {
id: string;
title: string;
start: string;
end: string;
url?: string;
similarity: number;
};
calendarId: string;
suggestion: string;
}
/**
* Response format for listing calendar events
*/
export interface ListEventsResponse {
events: StructuredEvent[];
totalCount: number;
calendars?: string[];
}
/**
* Response format for searching calendar events
*/
export interface SearchEventsResponse {
events: StructuredEvent[];
totalCount: number;
query: string;
calendarId: string;
timeRange?: {
start: string;
end: string;
};
}
/**
* Response format for getting a single event by ID
*/
export interface GetEventResponse {
event: StructuredEvent;
}
/**
* Response format for creating a new event
*/
export interface CreateEventResponse {
event: StructuredEvent;
conflicts?: ConflictInfo[];
duplicates?: DuplicateInfo[];
warnings?: string[];
}
/**
* Response format for updating an existing event
*/
export interface UpdateEventResponse {
event: StructuredEvent;
conflicts?: ConflictInfo[];
warnings?: string[];
}
/**
* Response format for deleting an event
*/
export interface DeleteEventResponse {
success: boolean;
eventId: string;
calendarId: string;
message?: string;
}
/**
* Detailed information about a calendar
*/
export interface CalendarInfo {
id: string;
summary?: string;
description?: string;
location?: string;
timeZone?: string;
summaryOverride?: string;
colorId?: string;
backgroundColor?: string;
foregroundColor?: string;
hidden?: boolean;
selected?: boolean;
accessRole?: string;
defaultReminders?: Reminder[];
notificationSettings?: {
notifications?: Array<{
type?: string;
method?: string;
}>;
};
primary?: boolean;
deleted?: boolean;
conferenceProperties?: {
allowedConferenceSolutionTypes?: string[];
};
}
/**
* Response format for listing available calendars
*/
export interface ListCalendarsResponse {
calendars: CalendarInfo[];
totalCount: number;
}
/**
* Color scheme definition with background and foreground colors
*/
export interface ColorDefinition {
background: string;
foreground: string;
}
/**
* Response format for available calendar and event colors
*/
export interface ListColorsResponse {
event: Record<string, ColorDefinition>;
calendar: Record<string, ColorDefinition>;
}
/**
* Represents a busy time period in free/busy queries
*/
export interface BusySlot {
start: string;
end: string;
}
/**
* Response format for free/busy time queries
*/
export interface FreeBusyResponse {
timeMin: string;
timeMax: string;
calendars: Record<string, {
busy: BusySlot[];
errors?: Array<{
domain?: string;
reason?: string;
}>;
}>;
}
/**
* Response format for getting the current time in a specific timezone
*/
export interface GetCurrentTimeResponse {
currentTime: string;
timezone: string;
offset: string;
isDST?: boolean;
}
/**
* Converts a Google Calendar API event to our structured format
* @param event - The Google Calendar API event object
* @param calendarId - Optional calendar ID to include in the response
* @returns Structured event representation
*/
export function convertGoogleEventToStructured(
event: calendar_v3.Schema$Event,
calendarId?: string
): StructuredEvent {
return {
id: event.id || '',
summary: event.summary ?? undefined,
description: event.description ?? undefined,
location: event.location ?? undefined,
start: {
dateTime: event.start?.dateTime ?? undefined,
date: event.start?.date ?? undefined,
timeZone: event.start?.timeZone ?? undefined,
},
end: {
dateTime: event.end?.dateTime ?? undefined,
date: event.end?.date ?? undefined,
timeZone: event.end?.timeZone ?? undefined,
},
status: event.status ?? undefined,
htmlLink: event.htmlLink ?? undefined,
created: event.created ?? undefined,
updated: event.updated ?? undefined,
colorId: event.colorId ?? undefined,
creator: event.creator ? {
email: event.creator.email ?? '',
displayName: event.creator.displayName ?? undefined,
self: event.creator.self ?? undefined,
} : undefined,
organizer: event.organizer ? {
email: event.organizer.email ?? '',
displayName: event.organizer.displayName ?? undefined,
self: event.organizer.self ?? undefined,
} : undefined,
attendees: event.attendees?.map(a => ({
email: a.email || '',
displayName: a.displayName ?? undefined,
responseStatus: a.responseStatus as any,
optional: a.optional ?? undefined,
organizer: a.organizer ?? undefined,
self: a.self ?? undefined,
resource: a.resource ?? undefined,
comment: a.comment ?? undefined,
additionalGuests: a.additionalGuests ?? undefined,
})),
recurrence: event.recurrence ?? undefined,
recurringEventId: event.recurringEventId ?? undefined,
originalStartTime: event.originalStartTime ? {
dateTime: event.originalStartTime.dateTime ?? undefined,
date: event.originalStartTime.date ?? undefined,
timeZone: event.originalStartTime.timeZone ?? undefined,
} : undefined,
transparency: event.transparency as any,
visibility: event.visibility as any,
iCalUID: event.iCalUID ?? undefined,
sequence: event.sequence ?? undefined,
reminders: event.reminders ? {
useDefault: event.reminders.useDefault ?? undefined,
overrides: event.reminders.overrides?.map(r => ({
method: (r.method as any) || 'popup',
minutes: r.minutes || 0,
})),
} : undefined,
source: event.source ? {
url: event.source.url ?? undefined,
title: event.source.title ?? undefined,
} : undefined,
attachments: event.attachments?.map(a => ({
fileUrl: a.fileUrl ?? undefined,
title: a.title ?? undefined,
mimeType: a.mimeType ?? undefined,
iconLink: a.iconLink ?? undefined,
fileId: a.fileId ?? undefined,
})),
eventType: event.eventType as any,
conferenceData: event.conferenceData as ConferenceData,
extendedProperties: event.extendedProperties as ExtendedProperties,
hangoutLink: event.hangoutLink ?? undefined,
anyoneCanAddSelf: event.anyoneCanAddSelf ?? undefined,
guestsCanInviteOthers: event.guestsCanInviteOthers ?? undefined,
guestsCanModify: event.guestsCanModify ?? undefined,
guestsCanSeeOtherGuests: event.guestsCanSeeOtherGuests ?? undefined,
privateCopy: event.privateCopy ?? undefined,
locked: event.locked ?? undefined,
calendarId: calendarId,
};
}
```
--------------------------------------------------------------------------------
/src/tests/unit/schemas/enhanced-properties.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from 'vitest';
import { ToolSchemas } from '../../../tools/registry.js';
describe('Enhanced Create-Event Properties', () => {
const createEventSchema = ToolSchemas['create-event'];
const baseEvent = {
calendarId: 'primary',
summary: 'Test Event',
start: '2025-01-20T10:00:00',
end: '2025-01-20T11:00:00'
};
describe('Guest Management Properties', () => {
it('should accept transparency values', () => {
expect(() => createEventSchema.parse({
...baseEvent,
transparency: 'opaque'
})).not.toThrow();
expect(() => createEventSchema.parse({
...baseEvent,
transparency: 'transparent'
})).not.toThrow();
});
it('should reject invalid transparency values', () => {
expect(() => createEventSchema.parse({
...baseEvent,
transparency: 'invalid'
})).toThrow();
});
it('should accept visibility values', () => {
const validVisibilities = ['default', 'public', 'private', 'confidential'];
validVisibilities.forEach(visibility => {
expect(() => createEventSchema.parse({
...baseEvent,
visibility
})).not.toThrow();
});
});
it('should accept guest permission booleans', () => {
const event = {
...baseEvent,
guestsCanInviteOthers: false,
guestsCanModify: true,
guestsCanSeeOtherGuests: false,
anyoneCanAddSelf: true
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept sendUpdates values', () => {
const validSendUpdates = ['all', 'externalOnly', 'none'];
validSendUpdates.forEach(sendUpdates => {
expect(() => createEventSchema.parse({
...baseEvent,
sendUpdates
})).not.toThrow();
});
});
});
describe('Conference Data', () => {
it('should accept valid conference data', () => {
const event = {
...baseEvent,
conferenceData: {
createRequest: {
requestId: 'unique-123',
conferenceSolutionKey: {
type: 'hangoutsMeet'
}
}
}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept all conference solution types', () => {
const types = ['hangoutsMeet', 'eventHangout', 'eventNamedHangout', 'addOn'];
types.forEach(type => {
const event = {
...baseEvent,
conferenceData: {
createRequest: {
requestId: `req-${type}`,
conferenceSolutionKey: { type }
}
}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
});
it('should reject conference data without required fields', () => {
expect(() => createEventSchema.parse({
...baseEvent,
conferenceData: {
createRequest: {
requestId: 'test'
// Missing conferenceSolutionKey
}
}
})).toThrow();
});
});
describe('Extended Properties', () => {
it('should accept extended properties', () => {
const event = {
...baseEvent,
extendedProperties: {
private: {
key1: 'value1',
key2: 'value2'
},
shared: {
sharedKey: 'sharedValue'
}
}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept only private properties', () => {
const event = {
...baseEvent,
extendedProperties: {
private: { app: 'myapp' }
}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept only shared properties', () => {
const event = {
...baseEvent,
extendedProperties: {
shared: { category: 'meeting' }
}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept empty extended properties object', () => {
const event = {
...baseEvent,
extendedProperties: {}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
});
describe('Attachments', () => {
it('should accept attachments array', () => {
const event = {
...baseEvent,
attachments: [
{
fileUrl: 'https://example.com/file.pdf',
title: 'Document',
mimeType: 'application/pdf',
iconLink: 'https://example.com/icon.png',
fileId: 'file123'
}
]
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept minimal attachment (only fileUrl)', () => {
const event = {
...baseEvent,
attachments: [
{ fileUrl: 'https://example.com/file.pdf' }
]
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept multiple attachments', () => {
const event = {
...baseEvent,
attachments: [
{ fileUrl: 'https://example.com/file1.pdf' },
{ fileUrl: 'https://example.com/file2.doc', title: 'Doc' },
{ fileUrl: 'https://example.com/file3.xls', mimeType: 'application/excel' }
]
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should reject attachments without fileUrl', () => {
expect(() => createEventSchema.parse({
...baseEvent,
attachments: [
{ title: 'Document' } // Missing fileUrl
]
})).toThrow();
});
});
describe('Enhanced Attendees', () => {
it('should accept attendees with all optional fields', () => {
const event = {
...baseEvent,
attendees: [
{
email: '[email protected]',
displayName: 'Test User',
optional: true,
responseStatus: 'accepted',
comment: 'Looking forward to it',
additionalGuests: 2
}
]
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should accept all response status values', () => {
const statuses = ['needsAction', 'declined', 'tentative', 'accepted'];
statuses.forEach(responseStatus => {
const event = {
...baseEvent,
attendees: [
{ email: '[email protected]', responseStatus }
]
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
});
it('should accept attendees with only email', () => {
const event = {
...baseEvent,
attendees: [
{ email: '[email protected]' }
]
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should reject attendees without email', () => {
expect(() => createEventSchema.parse({
...baseEvent,
attendees: [
{ displayName: 'No Email User' }
]
})).toThrow();
});
it('should reject negative additional guests', () => {
expect(() => createEventSchema.parse({
...baseEvent,
attendees: [
{ email: '[email protected]', additionalGuests: -1 }
]
})).toThrow();
});
});
describe('Source Property', () => {
it('should accept source with url and title', () => {
const event = {
...baseEvent,
source: {
url: 'https://example.com/event/123',
title: 'External Event System'
}
};
expect(() => createEventSchema.parse(event)).not.toThrow();
});
it('should reject source without url', () => {
expect(() => createEventSchema.parse({
...baseEvent,
source: { title: 'No URL' }
})).toThrow();
});
it('should reject source without title', () => {
expect(() => createEventSchema.parse({
...baseEvent,
source: { url: 'https://example.com' }
})).toThrow();
});
});
describe('Combined Properties', () => {
it('should accept event with all enhanced properties', () => {
const complexEvent = {
...baseEvent,
eventId: 'custom-id-123',
description: 'Complex event with all features',
location: 'Conference Room',
transparency: 'opaque',
visibility: 'public',
guestsCanInviteOthers: true,
guestsCanModify: false,
guestsCanSeeOtherGuests: true,
anyoneCanAddSelf: false,
sendUpdates: 'all',
conferenceData: {
createRequest: {
requestId: 'conf-123',
conferenceSolutionKey: { type: 'hangoutsMeet' }
}
},
extendedProperties: {
private: { appId: '123' },
shared: { category: 'meeting' }
},
attachments: [
{ fileUrl: 'https://example.com/agenda.pdf', title: 'Agenda' }
],
attendees: [
{
email: '[email protected]',
displayName: 'Alice',
optional: false,
responseStatus: 'accepted'
},
{
email: '[email protected]',
displayName: 'Bob',
optional: true,
responseStatus: 'tentative',
additionalGuests: 1
}
],
source: {
url: 'https://example.com/source',
title: 'Source System'
},
colorId: '5',
reminders: {
useDefault: false,
overrides: [{ method: 'popup', minutes: 15 }]
}
};
expect(() => createEventSchema.parse(complexEvent)).not.toThrow();
});
it('should maintain backward compatibility with minimal event', () => {
// Only required fields
const minimalEvent = {
calendarId: 'primary',
summary: 'Simple Event',
start: '2025-01-20T10:00:00',
end: '2025-01-20T11:00:00'
};
expect(() => createEventSchema.parse(minimalEvent)).not.toThrow();
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/integration/claude-mcp-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import Anthropic from '@anthropic-ai/sdk';
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
/**
* Minimal Claude + MCP Integration Tests
*
* PURPOSE: Test ONLY what's unique to LLM integration:
* 1. Can Claude understand user intent and select appropriate tools?
* 2. Can Claude handle multi-step reasoning?
* 3. Can Claude handle ambiguous requests appropriately?
*
* NOT TESTED HERE (covered in direct-integration.test.ts):
* - Tool functionality
* - Conflict detection
* - Calendar operations
* - Error handling
* - Performance
*/
interface LLMResponse {
content: string;
toolCalls: Array<{ name: string; arguments: Record<string, any> }>;
executedResults: Array<{
toolCall: { name: string; arguments: Record<string, any> };
result: any;
success: boolean;
}>;
}
class ClaudeMCPClient {
private anthropic: Anthropic;
private mcpClient: Client;
constructor(apiKey: string, mcpClient: Client) {
this.anthropic = new Anthropic({ apiKey });
this.mcpClient = mcpClient;
}
async sendMessage(prompt: string): Promise<LLMResponse> {
// Get available tools from MCP server
const availableTools = await this.mcpClient.listTools();
const model = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5-20250929';
// Convert MCP tools to Claude format
const claudeTools = availableTools.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema
}));
// Send to Claude
const message = await this.anthropic.messages.create({
model,
max_tokens: 2500,
tools: claudeTools,
messages: [{
role: 'user' as const,
content: prompt
}]
});
// Extract tool calls
const toolCalls: Array<{ name: string; arguments: Record<string, any> }> = [];
let textContent = '';
message.content.forEach(content => {
if (content.type === 'text') {
textContent += content.text;
} else if (content.type === 'tool_use') {
toolCalls.push({
name: content.name,
arguments: content.input as Record<string, any>
});
}
});
// Execute tool calls
const executedResults = [];
for (const toolCall of toolCalls) {
try {
const result = await this.mcpClient.callTool({
name: toolCall.name,
arguments: toolCall.arguments
});
executedResults.push({
toolCall,
result,
success: true
});
} catch (error) {
executedResults.push({
toolCall,
result: { error: String(error) },
success: false
});
}
}
return {
content: textContent,
toolCalls,
executedResults
};
}
}
describe('Claude + MCP Essential Tests', () => {
let mcpClient: Client;
let claudeClient: ClaudeMCPClient;
beforeAll(async () => {
// Start MCP server
const cleanEnv = Object.fromEntries(
Object.entries(process.env).filter(([_, value]) => value !== undefined)
) as Record<string, string>;
cleanEnv.NODE_ENV = 'test';
// Create MCP client
mcpClient = new Client({
name: "minimal-test-client",
version: "1.0.0"
}, {
capabilities: { tools: {} }
});
// Connect to server
const transport = new StdioClientTransport({
command: 'node',
args: ['build/index.js'],
env: cleanEnv
});
await mcpClient.connect(transport);
// Initialize Claude client
const apiKey = process.env.CLAUDE_API_KEY;
if (!apiKey) {
throw new Error('CLAUDE_API_KEY not set');
}
claudeClient = new ClaudeMCPClient(apiKey, mcpClient);
// Verify connection
const tools = await mcpClient.listTools();
console.log(`Connected to MCP with ${tools.tools.length} tools available`);
}, 30000);
afterAll(async () => {
if (mcpClient) await mcpClient.close();
}, 10000);
describe('Core LLM Capabilities', () => {
it('should select appropriate tools for user intent', async () => {
const testCases = [
{
intent: 'create',
prompt: 'Schedule a meeting tomorrow at 3 PM',
expectedTools: ['create-event', 'get-current-time']
},
{
intent: 'search',
prompt: 'Find my meetings with Sarah',
expectedTools: ['search-events', 'list-events', 'get-current-time']
},
{
intent: 'availability',
prompt: 'Am I free tomorrow afternoon?',
expectedTools: ['get-freebusy', 'list-events', 'get-current-time']
}
];
for (const test of testCases) {
const response = await claudeClient.sendMessage(test.prompt);
// Check if Claude used one of the expected tools
const usedExpectedTool = response.toolCalls.some(tc =>
test.expectedTools.includes(tc.name)
);
// Or at least understood the intent in its response
const understoodIntent =
usedExpectedTool ||
response.content.toLowerCase().includes(test.intent);
expect(understoodIntent).toBe(true);
}
}, 60000);
it('should handle multi-step requests', async () => {
const response = await claudeClient.sendMessage(
'What time is it now, and do I have any meetings in the next 2 hours?'
);
// This requires multiple tool calls or understanding multiple parts
const handledMultiStep =
response.toolCalls.length > 1 || // Multiple tools used
(response.toolCalls.some(tc => tc.name === 'get-current-time') &&
response.toolCalls.some(tc => tc.name === 'list-events')) || // Both time and events
(response.content.includes('time') && response.content.includes('meeting')); // Understood both parts
expect(handledMultiStep).toBe(true);
}, 30000);
it('should handle ambiguous requests gracefully', async () => {
const response = await claudeClient.sendMessage(
'Set up the usual'
);
// Claude should either:
// 1. Ask for clarification
// 2. Make a reasonable attempt with available context
// 3. Explain what information is needed
const handledGracefully =
response.content.toLowerCase().includes('what') ||
response.content.toLowerCase().includes('specify') ||
response.content.toLowerCase().includes('usual') ||
response.content.toLowerCase().includes('more') ||
response.toolCalls.length > 0; // Or attempts something
expect(handledGracefully).toBe(true);
}, 30000);
});
describe('Tool Selection Accuracy', () => {
it('should distinguish between list and search operations', async () => {
// Specific search should use search-events
const searchResponse = await claudeClient.sendMessage(
'Find meetings about project alpha'
);
const usedSearch =
searchResponse.toolCalls.some(tc => tc.name === 'search-events') ||
searchResponse.content.toLowerCase().includes('search');
// General list should use list-events
const listResponse = await claudeClient.sendMessage(
'Show me tomorrow\'s schedule'
);
const usedList =
listResponse.toolCalls.some(tc => tc.name === 'list-events') ||
listResponse.content.toLowerCase().includes('tomorrow');
// At least one should be correct
expect(usedSearch || usedList).toBe(true);
}, 30000);
it('should understand when NOT to use tools', async () => {
const response = await claudeClient.sendMessage(
'How does Google Calendar handle recurring events?'
);
// This is a question about calendars, not a calendar operation
// Claude should either:
// 1. Not use tools and explain
// 2. Use minimal tools (like list-calendars) to provide context
const appropriateResponse =
response.toolCalls.length === 0 || // No tools
response.toolCalls.length === 1 && response.toolCalls[0].name === 'list-calendars' || // Just checking calendars
response.content.toLowerCase().includes('recurring'); // Explains about recurring events
expect(appropriateResponse).toBe(true);
}, 30000);
});
describe('Context Understanding', () => {
it('should understand relative time expressions', async () => {
const testPhrases = [
'tomorrow at 2 PM',
'next Monday',
'in 30 minutes'
];
for (const phrase of testPhrases) {
const response = await claudeClient.sendMessage(
`Schedule a meeting ${phrase}`
);
// Claude should either get current time or attempt to create an event
const understoodTime =
response.toolCalls.some(tc =>
tc.name === 'get-current-time' ||
tc.name === 'create-event'
) ||
response.content.toLowerCase().includes(phrase.split(' ')[0]); // References the time
expect(understoodTime).toBe(true);
}
}, 60000);
});
});
/**
* What we removed:
* ✂️ All conflict detection tests (tested in direct integration)
* ✂️ Duplicate detection tests (tested in direct integration)
* ✂️ Conference room booking tests (business logic, not LLM)
* ✂️ Back-to-back meeting tests (calendar logic, not LLM)
* ✂️ Specific warning message tests (tool behavior, not LLM)
* ✂️ Performance tests (server performance, not LLM)
* ✂️ Complex multi-event creation tests (tool functionality)
*
* What remains:
* ✅ Tool selection for different intents (core LLM capability)
* ✅ Multi-step request handling (LLM reasoning)
* ✅ Ambiguous request handling (LLM robustness)
* ✅ Context understanding (LLM comprehension)
* ✅ Knowing when NOT to use tools (LLM judgment)
*/
```
--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------
```markdown
# Testing Guide
## Quick Start
```bash
npm test # Unit tests (no auth required)
npm run test:integration # Integration tests (requires Google auth)
npm run test:all # All tests (requires Google auth + LLM API keys)
```
## Test Structure
- `src/tests/unit/` - Unit tests (mocked, no external dependencies)
- `src/tests/integration/` - Integration tests (real Google Calendar API calls)
## Unit Tests
**Requirements:** None - fully self-contained
**Coverage:**
- Request validation and schema compliance
- Error handling and edge cases
- Date/time parsing and timezone conversion logic
- Mock-based handler functionality
- Tool registration and validation
**Run with:**
```bash
npm test
```
## Integration Tests
Integration tests are divided into three categories based on their requirements:
### 1. Direct Google Calendar Integration
**Files:** `direct-integration.test.ts`
**Requirements:**
- Google OAuth credentials file
- Authenticated test account
- Real Google Calendar access
**Setup:**
```bash
# Set environment variables
export GOOGLE_OAUTH_CREDENTIALS="path/to/your/oauth-credentials.json"
export TEST_CALENDAR_ID="your-test-calendar-id"
# Authenticate test account
npm run dev auth:test
```
**What these tests do:**
- ✅ Create, read, update, delete real calendar events
- ✅ Test multi-calendar operations with batch requests
- ✅ Validate timezone handling with actual Google Calendar API
- ✅ Test recurring event patterns and modifications
- ✅ Verify free/busy queries and calendar listings
- ✅ Performance benchmarking with real API latency
**⚠️ Warning:** These tests modify real calendar data in your test calendar.
### 2. LLM Integration Tests
**Files:** `claude-mcp-integration.test.ts`, `openai-mcp-integration.test.ts`
**Requirements:**
- Google OAuth credentials + authenticated test account (from above)
- LLM API keys
- **LLM models that support MCP (Claude) or function calling (OpenAI)**
**Additional setup:**
```bash
# Set LLM API keys
export CLAUDE_API_KEY="your-claude-api-key"
export OPENAI_API_KEY="your-openai-api-key"
# Optional: specify models (must support MCP/function calling)
export ANTHROPIC_MODEL="claude-3-5-haiku-20241022" # Default
export OPENAI_MODEL="gpt-4o-mini" # Default
```
**What these tests do:**
- ✅ Test end-to-end MCP protocol integration with Claude
- ✅ Test end-to-end MCP protocol integration with OpenAI
- ✅ Validate AI assistant can successfully call calendar tools
- ✅ Test complex multi-step AI workflows
**⚠️ Warning:** These tests consume LLM API credits and modify real calendar data.
**Important LLM Compatibility Notes:**
- **Claude**: Only Claude 3.5+ models support MCP. Earlier models will fail.
- **OpenAI**: Only GPT-4+ and select GPT-3.5-turbo models support function calling.
- If you see "tool not found" or "function not supported" errors, verify your model selection.
### 3. Docker Integration Tests
**Files:** `docker-integration.test.ts`
**Requirements:**
- Docker installed and running
- Google OAuth credentials
**What these tests do:**
- ✅ Test containerized deployment
- ✅ Validate HTTP transport mode
- ✅ Test Docker environment configuration
### Running Specific Integration Test Types
```bash
# Run only direct Google Calendar integration tests
npm run test:integration -- direct-integration.test.ts
# Run only LLM integration tests (requires API keys)
npm run test:integration -- claude-mcp-integration.test.ts
npm run test:integration -- openai-mcp-integration.test.ts
# Run all integration tests (requires both Google auth + LLM API keys)
npm run test:integration
```
## Environment Configuration
### Required Environment Variables
| Variable | Required For | Purpose | Example |
|----------|--------------|---------|---------|
| `GOOGLE_OAUTH_CREDENTIALS` | All integration tests | Path to OAuth credentials file | `./gcp-oauth.keys.json` |
| `TEST_CALENDAR_ID` | All integration tests | Target calendar for test operations | `[email protected]` or `primary` |
| `CLAUDE_API_KEY` | Claude integration tests | Anthropic API access | `sk-ant-api03-...` |
| `OPENAI_API_KEY` | OpenAI integration tests | OpenAI API access | `sk-...` |
| `INVITEE_1` | Attendee tests | Test attendee email | `[email protected]` |
| `INVITEE_2` | Attendee tests | Test attendee email | `[email protected]` |
### Optional Environment Variables
| Variable | Purpose | Default | Notes |
|----------|---------|---------|-------|
| `GOOGLE_ACCOUNT_MODE` | Account mode | `normal` | Use `test` for testing |
| `DEBUG_LLM_INTERACTIONS` | Debug logging | `false` | Set `true` for verbose LLM logs |
| `ANTHROPIC_MODEL` | Claude model | `claude-3-5-haiku-20241022` | Must support MCP |
| `OPENAI_MODEL` | OpenAI model | `gpt-4o-mini` | Must support function calling |
### Complete Setup Example
1. **Create `.env` file in project root:**
```env
# Required for all integration tests
GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
[email protected]
# Required for LLM integration tests
CLAUDE_API_KEY=sk-ant-api03-...
OPENAI_API_KEY=sk-...
# Required for attendee tests
[email protected]
[email protected]
# Optional configurations
GOOGLE_ACCOUNT_MODE=test
DEBUG_LLM_INTERACTIONS=false
ANTHROPIC_MODEL=claude-3-5-haiku-20241022
OPENAI_MODEL=gpt-4o-mini
```
2. **Obtain Google OAuth Credentials:**
- Go to [Google Cloud Console](https://console.cloud.google.com)
- Create a new project or select existing
- Enable Google Calendar API
- Create OAuth 2.0 credentials (Desktop app type)
- Download credentials JSON file
- Save as `gcp-oauth.keys.json` in project root
3. **Authenticate Test Account:**
```bash
# Creates tokens in ~/.config/google-calendar-mcp/tokens.json
npm run dev auth:test
```
4. **Verify Setup:**
```bash
# Check authentication status
npm run dev account:status
# Run a simple integration test
npm run test:integration -- direct-integration.test.ts
```
## Troubleshooting
### Common Issues
**Authentication Errors:**
- **"No credentials found"**: Run `npm run dev auth:test` to authenticate
- **"Token expired"**: Re-authenticate with `npm run dev auth:test`
- **"Invalid credentials"**: Check `GOOGLE_OAUTH_CREDENTIALS` path is correct
- **"Refresh token must be passed"**: Delete tokens and re-authenticate
**API Errors:**
- **Rate limits**: Tests include retry logic, but may still hit limits with frequent runs
- **Calendar not found**: Verify `TEST_CALENDAR_ID` exists and is accessible
- **Permission denied**: Ensure test account has write access to the calendar
- **"Invalid time range"**: Free/busy queries limited to 3 months between timeMin and timeMax
**LLM Integration Errors:**
- **"Invalid API key"**: Check `CLAUDE_API_KEY`/`OPENAI_API_KEY` are set correctly
- **"Insufficient credits"**: LLM tests consume API credits - ensure account has balance
- **"Model not found"**: Verify model name and availability in your API plan
- **"Tool not found" or "Function not supported"**:
- Claude: Ensure using Claude 3.5+ model that supports MCP
- OpenAI: Ensure using GPT-4+ or compatible GPT-3.5-turbo model
- **"Maximum tokens exceeded"**: Some complex tests may hit token limits with verbose models
- **Network timeouts**: LLM tests may take 2-5 minutes due to AI processing time
**Docker Integration Errors:**
- **"Docker not found"**: Ensure Docker is installed and running
- **Port conflicts**: Docker tests use port 3000 - ensure it's available
- **Build failures**: Check Docker build logs for missing dependencies
- **"Cannot connect to Docker daemon"**: Start Docker Desktop or daemon
### Test Data Management
**Calendar Cleanup:**
- Tests attempt to clean up created events automatically
- Failed tests may leave test events in your calendar
- Manually delete events with "Integration Test" or "Test Event" in the title if needed
**Test Isolation:**
- Use a dedicated test calendar (`TEST_CALENDAR_ID`)
- Don't use your personal calendar for testing
- Consider creating a separate Google account for testing
### Performance Considerations
**Test Duration:**
- Unit tests: ~2 seconds
- Direct integration tests: ~30-60 seconds
- LLM integration tests: ~2-5 minutes (due to AI processing)
- Full test suite: ~5-10 minutes
**Parallel Execution:**
- Unit tests run in parallel by default
- Integration tests run sequentially to avoid API conflicts
- Use `--reporter=verbose` for detailed progress during long test runs
## Development Tips
### Debugging Integration Tests
1. **Enable Debug Logging:**
```bash
# Debug all LLM interactions
export DEBUG_LLM_INTERACTIONS=true
# Debug MCP server
export DEBUG=mcp:*
```
2. **Run Single Test:**
```bash
# Run specific test by name pattern
npm run test:integration -- -t "should handle timezone"
```
3. **Interactive Testing:**
```bash
# Use the dev menu for quick access to test commands
npm run dev
```
### Writing New Integration Tests
1. **Use Test Data Factory:**
```typescript
import { TestDataFactory } from './test-data-factory.js';
const factory = new TestDataFactory();
const testEvent = factory.createTestEvent({
summary: 'My Test Event',
start: factory.getTomorrowAt(14, 0),
end: factory.getTomorrowAt(15, 0)
});
```
2. **Track Created Events:**
```typescript
// Events are automatically tracked for cleanup
const eventId = TestDataFactory.extractEventIdFromResponse(result);
```
3. **LLM Context Logging:**
```typescript
// Wrap LLM operations for automatic error logging
await executeWithContextLogging('Test Name', async () => {
const response = await llmClient.sendMessage('...');
// Test assertions
});
```
### Best Practices
1. **Environment Isolation:**
- Always use `GOOGLE_ACCOUNT_MODE=test` for testing
- Use a dedicated test calendar, not personal calendar
- Consider separate Google account for testing
2. **Cost Management:**
- LLM tests consume API credits
- Run specific tests during development
- Use smaller/cheaper models for initial testing
3. **Test Data:**
- Tests auto-cleanup created events
- Use unique event titles with timestamps
- Verify cleanup in afterEach hooks
4. **Debugging Failures:**
- Check `DEBUG_LLM_INTERACTIONS` output for LLM tests
- Verify model compatibility for tool/function support
- Check API quotas and rate limits
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/ListEventsHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
import { OAuth2Client } from 'google-auth-library';
import { google } from 'googleapis';
import { convertToRFC3339 } from '../../../handlers/utils/datetime.js';
// Mock googleapis globally
vi.mock('googleapis', () => ({
google: {
calendar: vi.fn(() => ({
events: {
list: vi.fn()
},
calendarList: {
get: vi.fn()
}
}))
}
}));
describe('ListEventsHandler JSON String Handling', () => {
const mockOAuth2Client = {
getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
} as unknown as OAuth2Client;
const handler = new ListEventsHandler();
let mockCalendar: any;
beforeEach(() => {
mockCalendar = {
events: {
list: vi.fn().mockResolvedValue({
data: {
items: [
{
id: 'test-event',
summary: 'Test Event',
start: { dateTime: '2025-06-02T10:00:00Z' },
end: { dateTime: '2025-06-02T11:00:00Z' },
}
]
}
})
},
calendarList: {
get: vi.fn().mockResolvedValue({
data: { timeZone: 'UTC' }
}),
list: vi.fn().mockResolvedValue({
data: {
items: [
{ id: 'primary', summary: 'Primary Calendar' },
{ id: '[email protected]', summary: 'Work Calendar' },
{ id: '[email protected]', summary: 'Personal Calendar' }
]
}
})
}
};
vi.mocked(google.calendar).mockReturnValue(mockCalendar);
});
// Mock fetch for batch requests
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(`--batch_boundary
Content-Type: application/http
Content-ID: <item1>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": [{"id": "test-event", "summary": "Test Event", "start": {"dateTime": "2025-06-02T10:00:00Z"}, "end": {"dateTime": "2025-06-02T11:00:00Z"}}]}
--batch_boundary--`)
});
it('should handle single calendar ID as string', async () => {
const args = {
calendarId: 'primary',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
const result = await handler.runTool(args, mockOAuth2Client);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toBeDefined();
expect(response.totalCount).toBeGreaterThanOrEqual(0);
});
it('should handle multiple calendar IDs as array', async () => {
const args = {
calendarId: ['primary', '[email protected]'],
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
const result = await handler.runTool(args, mockOAuth2Client);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toBeDefined();
expect(response.totalCount).toBeGreaterThanOrEqual(0);
});
it('should handle calendar IDs passed as JSON string', async () => {
// This simulates the problematic case from the user
const args = {
calendarId: '["primary", "[email protected]"]',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
// This would be parsed by the Zod transform before reaching the handler
// For testing, we'll manually simulate what the transform should do
let processedArgs = { ...args };
if (typeof args.calendarId === 'string' && args.calendarId.startsWith('[')) {
processedArgs.calendarId = JSON.parse(args.calendarId);
}
const result = await handler.runTool(processedArgs, mockOAuth2Client);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toBeDefined();
expect(response.totalCount).toBeGreaterThanOrEqual(0);
expect(response.calendars).toEqual(['primary', '[email protected]']);
});
});
describe('ListEventsHandler - Timezone Handling', () => {
let handler: ListEventsHandler;
let mockOAuth2Client: OAuth2Client;
let mockCalendar: any;
beforeEach(() => {
handler = new ListEventsHandler();
mockOAuth2Client = {} as OAuth2Client;
mockCalendar = {
events: {
list: vi.fn()
},
calendarList: {
get: vi.fn(),
list: vi.fn().mockResolvedValue({
data: {
items: [
{ id: 'primary', summary: 'Primary Calendar' },
{ id: '[email protected]', summary: 'Work Calendar' }
]
}
})
}
};
vi.mocked(google.calendar).mockReturnValue(mockCalendar);
});
describe('convertToRFC3339 timezone interpretation', () => {
it('should correctly convert timezone-naive datetime to Los Angeles time', () => {
// Test the core issue: timezone-naive datetime should be interpreted in the target timezone
const datetime = '2025-01-01T10:00:00';
const timezone = 'America/Los_Angeles';
const result = convertToRFC3339(datetime, timezone);
// In January 2025, Los Angeles is UTC-8 (PST)
// 10:00 AM PST = 18:00 UTC
// The result should be '2025-01-01T18:00:00Z'
expect(result).toBe('2025-01-01T18:00:00Z');
});
it('should correctly convert timezone-naive datetime to New York time', () => {
const datetime = '2025-01-01T10:00:00';
const timezone = 'America/New_York';
const result = convertToRFC3339(datetime, timezone);
// In January 2025, New York is UTC-5 (EST)
// 10:00 AM EST = 15:00 UTC
expect(result).toBe('2025-01-01T15:00:00Z');
});
it('should correctly convert timezone-naive datetime to London time', () => {
const datetime = '2025-01-01T10:00:00';
const timezone = 'Europe/London';
const result = convertToRFC3339(datetime, timezone);
// In January 2025, London is UTC+0 (GMT)
// 10:00 AM GMT = 10:00 UTC
expect(result).toBe('2025-01-01T10:00:00Z');
});
it('should handle DST transitions correctly', () => {
// Test during DST period
const datetime = '2025-07-01T10:00:00';
const timezone = 'America/Los_Angeles';
const result = convertToRFC3339(datetime, timezone);
// In July 2025, Los Angeles is UTC-7 (PDT)
// 10:00 AM PDT = 17:00 UTC
expect(result).toBe('2025-07-01T17:00:00Z');
});
it('should leave timezone-aware datetime unchanged', () => {
const datetime = '2025-01-01T10:00:00-08:00';
const timezone = 'America/Los_Angeles';
const result = convertToRFC3339(datetime, timezone);
// Should remain unchanged since it already has timezone info
expect(result).toBe('2025-01-01T10:00:00-08:00');
});
});
describe('ListEventsHandler timezone parameter usage', () => {
beforeEach(() => {
// Mock successful calendar list response
mockCalendar.calendarList.get.mockResolvedValue({
data: { timeZone: 'UTC' }
});
// Mock successful events list response
mockCalendar.events.list.mockResolvedValue({
data: { items: [] }
});
});
it('should use timeZone parameter to interpret timezone-naive timeMin/timeMax', async () => {
const args = {
calendarId: 'primary',
timeMin: '2025-01-01T10:00:00',
timeMax: '2025-01-01T18:00:00',
timeZone: 'America/Los_Angeles'
};
await handler.runTool(args, mockOAuth2Client);
// Verify that the calendar.events.list was called with correctly converted times
expect(mockCalendar.events.list).toHaveBeenCalledWith({
calendarId: 'primary',
timeMin: '2025-01-01T18:00:00Z', // 10:00 AM PST = 18:00 UTC
timeMax: '2025-01-02T02:00:00Z', // 18:00 PM PST = 02:00 UTC next day
singleEvents: true,
orderBy: 'startTime'
});
});
it('should preserve timezone-aware timeMin/timeMax regardless of timeZone parameter', async () => {
const args = {
calendarId: 'primary',
timeMin: '2025-01-01T10:00:00-08:00',
timeMax: '2025-01-01T18:00:00-08:00',
timeZone: 'America/New_York' // Different timezone, should be ignored
};
await handler.runTool(args, mockOAuth2Client);
// Verify that the original timezone-aware times are preserved
expect(mockCalendar.events.list).toHaveBeenCalledWith({
calendarId: 'primary',
timeMin: '2025-01-01T10:00:00-08:00',
timeMax: '2025-01-01T18:00:00-08:00',
singleEvents: true,
orderBy: 'startTime'
});
});
it('should fall back to calendar timezone when timeZone parameter not provided', async () => {
// Mock calendar with Los Angeles timezone
mockCalendar.calendarList.get.mockResolvedValue({
data: { timeZone: 'America/Los_Angeles' }
});
const args = {
calendarId: 'primary',
timeMin: '2025-01-01T10:00:00',
timeMax: '2025-01-01T18:00:00'
// No timeZone parameter
};
await handler.runTool(args, mockOAuth2Client);
// Verify that the calendar's timezone is used for conversion
expect(mockCalendar.events.list).toHaveBeenCalledWith({
calendarId: 'primary',
timeMin: '2025-01-01T18:00:00Z', // 10:00 AM PST = 18:00 UTC
timeMax: '2025-01-02T02:00:00Z', // 18:00 PM PST = 02:00 UTC next day
singleEvents: true,
orderBy: 'startTime'
});
});
it('should handle UTC timezone correctly', async () => {
const args = {
calendarId: 'primary',
timeMin: '2025-01-01T10:00:00',
timeMax: '2025-01-01T18:00:00',
timeZone: 'UTC'
};
await handler.runTool(args, mockOAuth2Client);
// Verify that UTC times are handled correctly
expect(mockCalendar.events.list).toHaveBeenCalledWith({
calendarId: 'primary',
timeMin: '2025-01-01T10:00:00Z',
timeMax: '2025-01-01T18:00:00Z',
singleEvents: true,
orderBy: 'startTime'
});
});
});
});
```
--------------------------------------------------------------------------------
/src/services/conflict-detection/ConflictDetectionService.ts:
--------------------------------------------------------------------------------
```typescript
import { OAuth2Client } from "google-auth-library";
import { google, calendar_v3 } from "googleapis";
import {
ConflictCheckResult,
InternalConflictInfo,
InternalDuplicateInfo,
ConflictDetectionOptions
} from "./types.js";
import { EventSimilarityChecker } from "./EventSimilarityChecker.js";
import { ConflictAnalyzer } from "./ConflictAnalyzer.js";
import { CONFLICT_DETECTION_CONFIG } from "./config.js";
import { getEventUrl } from "../../handlers/utils.js";
import { convertToRFC3339 } from "../../handlers/utils/datetime.js";
/**
* Service for detecting event conflicts and duplicates.
*
* IMPORTANT: This service relies on Google Calendar's list API to find existing events.
* Due to eventual consistency in Google Calendar, recently created events may not
* immediately appear in list queries. This is a known limitation of the Google Calendar API
* and affects duplicate detection for events created in quick succession.
*
* In real-world usage, this is rarely an issue as there's natural time between event creation.
*/
export class ConflictDetectionService {
private similarityChecker: EventSimilarityChecker;
private conflictAnalyzer: ConflictAnalyzer;
constructor() {
this.similarityChecker = new EventSimilarityChecker();
this.conflictAnalyzer = new ConflictAnalyzer();
}
/**
* Check for conflicts and duplicates when creating or updating an event
*/
async checkConflicts(
oauth2Client: OAuth2Client,
event: calendar_v3.Schema$Event,
calendarId: string,
options: ConflictDetectionOptions = {}
): Promise<ConflictCheckResult> {
const {
checkDuplicates = true,
checkConflicts = true,
calendarsToCheck = [calendarId],
duplicateSimilarityThreshold = CONFLICT_DETECTION_CONFIG.DEFAULT_DUPLICATE_THRESHOLD,
includeDeclinedEvents = false
} = options;
const result: ConflictCheckResult = {
hasConflicts: false,
conflicts: [],
duplicates: []
};
if (!event.start || !event.end) {
return result;
}
// Get the time range for checking
let timeMin = event.start.dateTime || event.start.date;
let timeMax = event.end.dateTime || event.end.date;
if (!timeMin || !timeMax) {
return result;
}
// Extract timezone if present (prefer start time's timezone)
const timezone = event.start.timeZone || event.end.timeZone;
// The Google Calendar API requires RFC3339 format for timeMin/timeMax
// If we have timezone-naive datetimes with a timezone field, convert them to proper RFC3339
// Check for minus but exclude the date separator (e.g., 2025-09-05)
const needsConversion = timezone && timeMin &&
!timeMin.includes('Z') &&
!timeMin.includes('+') &&
!timeMin.substring(10).includes('-'); // Only check for minus after the date part
if (needsConversion) {
timeMin = convertToRFC3339(timeMin, timezone);
timeMax = convertToRFC3339(timeMax, timezone);
}
// Use the exact time range provided for searching
// This ensures duplicate detection only flags events that actually overlap
const searchTimeMin = timeMin;
const searchTimeMax = timeMax;
// Check each calendar
for (const checkCalendarId of calendarsToCheck) {
try {
// Get events in the search time range, passing timezone for proper interpretation
const events = await this.getEventsInTimeRange(
oauth2Client,
checkCalendarId,
searchTimeMin,
searchTimeMax,
timezone || undefined
);
// Check for duplicates
if (checkDuplicates) {
const duplicates = this.findDuplicates(
event,
events,
checkCalendarId,
duplicateSimilarityThreshold
);
result.duplicates.push(...duplicates);
}
// Check for conflicts
if (checkConflicts) {
const conflicts = this.findConflicts(
event,
events,
checkCalendarId,
includeDeclinedEvents
);
result.conflicts.push(...conflicts);
}
} catch (error) {
// If we can't access a calendar, skip it silently
// Errors are expected for calendars without access permissions
}
}
result.hasConflicts = result.conflicts.length > 0 || result.duplicates.length > 0;
return result;
}
/**
* Get events in a specific time range from a calendar
*/
private async getEventsInTimeRange(
oauth2Client: OAuth2Client,
calendarId: string,
timeMin: string,
timeMax: string,
timeZone?: string
): Promise<calendar_v3.Schema$Event[]> {
// Fetch from API
const calendar = google.calendar({ version: "v3", auth: oauth2Client });
// Build list parameters
const listParams: any = {
calendarId,
timeMin,
timeMax,
singleEvents: true,
orderBy: 'startTime',
maxResults: 250
};
// The Google Calendar API accepts both:
// 1. Timezone-aware datetimes (with Z or offset)
// 2. Timezone-naive datetimes with a timeZone parameter
// We pass the timeZone parameter when available for consistency
if (timeZone) {
listParams.timeZone = timeZone;
}
// Use exact time range without extension to avoid false positives
const response = await calendar.events.list(listParams);
const events = response?.data?.items || [];
return events;
}
/**
* Find duplicate events based on similarity
*/
private findDuplicates(
newEvent: calendar_v3.Schema$Event,
existingEvents: calendar_v3.Schema$Event[],
calendarId: string,
threshold: number
): InternalDuplicateInfo[] {
const duplicates: InternalDuplicateInfo[] = [];
for (const existingEvent of existingEvents) {
// Skip if it's the same event (for updates)
if (existingEvent.id === newEvent.id) continue;
// Skip cancelled events
if (existingEvent.status === 'cancelled') continue;
const similarity = this.similarityChecker.checkSimilarity(newEvent, existingEvent);
if (similarity >= threshold) {
duplicates.push({
event: {
id: existingEvent.id!,
title: existingEvent.summary || 'Untitled Event',
url: getEventUrl(existingEvent, calendarId) || undefined,
similarity: Math.round(similarity * 100) / 100
},
fullEvent: existingEvent,
calendarId: calendarId,
suggestion: similarity >= CONFLICT_DETECTION_CONFIG.DUPLICATE_THRESHOLDS.BLOCKING
? 'This appears to be a duplicate. Consider updating the existing event instead.'
: 'This event is very similar to an existing one. Is this intentional?'
});
}
}
return duplicates;
}
/**
* Find conflicting events based on time overlap
*/
private findConflicts(
newEvent: calendar_v3.Schema$Event,
existingEvents: calendar_v3.Schema$Event[],
calendarId: string,
includeDeclinedEvents: boolean
): InternalConflictInfo[] {
const conflicts: InternalConflictInfo[] = [];
const overlappingEvents = this.conflictAnalyzer.findOverlappingEvents(existingEvents, newEvent);
for (const conflictingEvent of overlappingEvents) {
// Skip declined events if configured
if (!includeDeclinedEvents && this.isEventDeclined(conflictingEvent)) {
continue;
}
const overlap = this.conflictAnalyzer.analyzeOverlap(newEvent, conflictingEvent);
if (overlap.hasOverlap) {
conflicts.push({
type: 'overlap',
calendar: calendarId,
event: {
id: conflictingEvent.id!,
title: conflictingEvent.summary || 'Untitled Event',
url: getEventUrl(conflictingEvent, calendarId) || undefined,
start: conflictingEvent.start?.dateTime || conflictingEvent.start?.date || undefined,
end: conflictingEvent.end?.dateTime || conflictingEvent.end?.date || undefined
},
fullEvent: conflictingEvent,
overlap: {
duration: overlap.duration!,
percentage: overlap.percentage!,
startTime: overlap.startTime!,
endTime: overlap.endTime!
}
});
}
}
return conflicts;
}
/**
* Check if the current user has declined an event
*/
private isEventDeclined(_event: calendar_v3.Schema$Event): boolean {
// For now, we'll skip this check since we don't have easy access to the user's email
// This could be enhanced later by passing the user email through the service
return false;
}
/**
* Check for conflicts using free/busy data (alternative method)
*/
async checkConflictsWithFreeBusy(
oauth2Client: OAuth2Client,
eventToCheck: calendar_v3.Schema$Event,
calendarsToCheck: string[]
): Promise<InternalConflictInfo[]> {
const conflicts: InternalConflictInfo[] = [];
if (!eventToCheck.start || !eventToCheck.end) return conflicts;
const timeMin = eventToCheck.start.dateTime || eventToCheck.start.date;
const timeMax = eventToCheck.end.dateTime || eventToCheck.end.date;
if (!timeMin || !timeMax) return conflicts;
const calendar = google.calendar({ version: "v3", auth: oauth2Client });
try {
const freeBusyResponse = await calendar.freebusy.query({
requestBody: {
timeMin,
timeMax,
items: calendarsToCheck.map(id => ({ id }))
}
});
for (const [calendarId, calendarInfo] of Object.entries(freeBusyResponse.data.calendars || {})) {
if (calendarInfo.busy && calendarInfo.busy.length > 0) {
for (const busySlot of calendarInfo.busy) {
if (this.conflictAnalyzer.checkBusyConflict(eventToCheck, busySlot)) {
conflicts.push({
type: 'overlap',
calendar: calendarId,
event: {
id: 'busy-time',
title: 'Busy (details unavailable)',
start: busySlot.start || undefined,
end: busySlot.end || undefined
}
});
}
}
}
}
} catch (error) {
console.error('Failed to check free/busy:', error);
}
return conflicts;
}
}
```
--------------------------------------------------------------------------------
/src/tests/integration/test-data-factory.ts:
--------------------------------------------------------------------------------
```typescript
// Test data factory utilities for integration tests
export interface TestEvent {
id?: string;
summary: string;
description?: string;
start: string;
end: string;
timeZone?: string; // Optional for all-day events
location?: string;
attendees?: Array<{ email: string }>;
colorId?: string;
reminders?: {
useDefault: boolean;
overrides?: Array<{ method: "email" | "popup"; minutes: number }>;
};
recurrence?: string[];
modificationScope?: "thisAndFollowing" | "all" | "thisEventOnly";
originalStartTime?: string;
futureStartDate?: string;
calendarId?: string;
sendUpdates?: "all" | "externalOnly" | "none";
}
export interface PerformanceMetric {
operation: string;
startTime: number;
endTime: number;
duration: number;
success: boolean;
error?: string;
}
export class TestDataFactory {
private static readonly TEST_CALENDAR_ID = process.env.TEST_CALENDAR_ID || 'primary';
private createdEventIds: string[] = [];
private performanceMetrics: PerformanceMetric[] = [];
static getTestCalendarId(): string {
return TestDataFactory.TEST_CALENDAR_ID;
}
// Helper method to format dates in RFC3339 format without milliseconds
// For events with a timeZone field, use timezone-naive format to avoid conflicts
public static formatDateTimeRFC3339(date: Date): string {
const isoString = date.toISOString();
// Return timezone-naive format (without Z suffix) to work better with timeZone field
return isoString.replace(/\.\d{3}Z$/, '');
}
// Helper method to format dates in RFC3339 format with timezone (for search operations)
public static formatDateTimeRFC3339WithTimezone(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Event data generators
static createSingleEvent(overrides: Partial<TestEvent> = {}): TestEvent {
const now = new Date();
const start = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2 hours from now
const end = new Date(start.getTime() + 60 * 60 * 1000); // 1 hour duration
return {
summary: 'Test Integration Event',
description: 'Created by integration test suite',
start: this.formatDateTimeRFC3339(start),
end: this.formatDateTimeRFC3339(end),
timeZone: 'America/Los_Angeles',
location: 'Test Conference Room',
reminders: {
useDefault: false,
overrides: [{ method: 'popup', minutes: 15 }]
},
...overrides
};
}
static createAllDayEvent(overrides: Partial<TestEvent> = {}): TestEvent {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dayAfter = new Date(tomorrow);
dayAfter.setDate(dayAfter.getDate() + 1);
// For all-day events, use date-only format (YYYY-MM-DD)
const startDate = tomorrow.toISOString().split('T')[0];
const endDate = dayAfter.toISOString().split('T')[0];
return {
summary: 'Test All-Day Event',
description: 'All-day test event',
start: startDate,
end: endDate,
// Note: timeZone is not used for all-day events (they're date-only)
...overrides
};
}
static createRecurringEvent(overrides: Partial<TestEvent> = {}): TestEvent {
const start = new Date();
start.setDate(start.getDate() + 1); // Tomorrow
start.setHours(10, 0, 0, 0); // 10 AM
const end = new Date(start);
end.setHours(11, 0, 0, 0); // 11 AM
return {
summary: 'Test Recurring Meeting',
description: 'Weekly recurring test meeting',
start: this.formatDateTimeRFC3339(start),
end: this.formatDateTimeRFC3339(end),
timeZone: 'America/Los_Angeles',
location: 'Recurring Meeting Room',
recurrence: ['RRULE:FREQ=WEEKLY;COUNT=5'], // 5 weeks
reminders: {
useDefault: false,
overrides: [{ method: 'email', minutes: 1440 }] // 1 day before
},
...overrides
};
}
static createEventWithAttendees(overrides: Partial<TestEvent> = {}): TestEvent {
const invitee1 = process.env.INVITEE_1;
const invitee2 = process.env.INVITEE_2;
if (!invitee1 || !invitee2) {
throw new Error('INVITEE_1 and INVITEE_2 environment variables are required for creating events with attendees');
}
return this.createSingleEvent({
summary: 'Test Meeting with Attendees',
attendees: [
{ email: invitee1 },
{ email: invitee2 }
],
...overrides
});
}
static createColoredEvent(colorId: string, overrides: Partial<TestEvent> = {}): TestEvent {
return this.createSingleEvent({
summary: `Test Event - Color ${colorId}`,
colorId,
...overrides
});
}
// Time range generators
static getTimeRanges() {
const now = new Date();
return {
// Past week
pastWeek: {
timeMin: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)),
timeMax: this.formatDateTimeRFC3339WithTimezone(now)
},
// Next week
nextWeek: {
timeMin: this.formatDateTimeRFC3339WithTimezone(now),
timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000))
},
// Next month
nextMonth: {
timeMin: this.formatDateTimeRFC3339WithTimezone(now),
timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000))
},
// Large range (3 months)
threeMonths: {
timeMin: this.formatDateTimeRFC3339WithTimezone(now),
timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000))
}
};
}
// Performance tracking
startTimer(_operation: string): number {
return Date.now();
}
endTimer(operation: string, startTime: number, success: boolean, error?: string): void {
const endTime = Date.now();
const duration = endTime - startTime;
this.performanceMetrics.push({
operation,
startTime,
endTime,
duration,
success,
error
});
}
getPerformanceMetrics(): PerformanceMetric[] {
return [...this.performanceMetrics];
}
clearPerformanceMetrics(): void {
this.performanceMetrics = [];
}
// Event tracking for cleanup
addCreatedEventId(eventId: string): void {
this.createdEventIds.push(eventId);
}
getCreatedEventIds(): string[] {
return [...this.createdEventIds];
}
clearCreatedEventIds(): void {
this.createdEventIds = [];
}
// Search queries
static getSearchQueries() {
return [
'Test Integration',
'meeting',
'recurring',
'attendees',
'Conference Room',
'nonexistent_query_should_return_empty'
];
}
// Validation helpers
static validateEventResponse(response: any): boolean {
if (!response || !response.content || !Array.isArray(response.content)) {
return false;
}
const text = response.content[0]?.text;
// Accept empty strings for search operations - they indicate "no results found"
return typeof text === 'string';
}
static extractEventIdFromResponse(response: any): string | null {
const text = response.content[0]?.text;
if (!text) return null;
// Try to parse as JSON first (v2.0 structured response)
try {
const parsed = JSON.parse(text);
if (parsed.event?.id) {
return parsed.event.id;
}
} catch {
// Fall back to legacy text parsing
}
// Look for various event ID patterns in the response (legacy)
// Google Calendar event IDs can contain letters, numbers, underscores, and special characters
const patterns = [
/Event created: .* \(([^)]+)\)/, // Legacy format - Match anything within parentheses after "Event created:"
/Event updated: .* \(([^)]+)\)/, // Legacy format - Match anything within parentheses after "Event updated:"
/✅ Event created successfully[\s\S]*?([^\s\(]+) \(([^)]+)\)/, // New format - Extract ID from parentheses in event details
/✅ Event updated successfully[\s\S]*?([^\s\(]+) \(([^)]+)\)/, // New format - Extract ID from parentheses in event details
/Event ID: ([^\s]+)/, // Match non-whitespace characters after "Event ID:"
/Created event: .* \(ID: ([^)]+)\)/, // Match anything within parentheses after "ID:"
/\(([[email protected]]{10,})\)/, // Specific pattern for Google Calendar IDs with common characters
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
// For patterns with multiple capture groups, we want the event ID
// which is typically in the last parentheses
let eventId = match[match.length - 1] || match[1];
if (eventId) {
// Clean up the captured ID (trim whitespace)
eventId = eventId.trim();
// Basic validation - should be at least 10 characters
if (eventId.length >= 10) {
return eventId;
}
}
}
}
return null;
}
static extractAllEventIds(response: any): string[] {
const text = response.content[0]?.text;
if (!text) return [];
const eventIds: string[] = [];
// Look for event IDs in list format - they appear in parentheses after event titles
// Pattern: anything that looks like an event ID in parentheses
const pattern = /\(([[email protected]]{10,})\)/g;
let match;
while ((match = pattern.exec(text)) !== null) {
const eventId = match[1].trim();
// Basic validation - should be at least 10 characters and not contain spaces
if (eventId.length >= 10 && !eventId.includes(' ')) {
eventIds.push(eventId);
}
}
// Also look for Event ID: patterns
const idPattern = /Event ID:\s*([[email protected]]+)/g;
while ((match = idPattern.exec(text)) !== null) {
const eventId = match[1].trim();
if (eventId.length >= 10 && !eventIds.includes(eventId)) {
eventIds.push(eventId);
}
}
return eventIds;
}
// Error simulation helpers
static getInvalidTestData() {
return {
invalidCalendarId: 'invalid_calendar_id',
invalidEventId: 'invalid_event_id',
invalidTimeFormat: '2024-13-45T25:99:99Z',
invalidTimezone: 'Invalid/Timezone',
invalidEmail: 'not-an-email',
invalidColorId: '999',
malformedRecurrence: ['INVALID:RRULE'],
futureDateInPast: '2020-01-01T10:00:00Z'
};
}
}
```
--------------------------------------------------------------------------------
/src/handlers/core/UpdateEventHandler.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { OAuth2Client } from "google-auth-library";
import { UpdateEventInput } from "../../tools/registry.js";
import { BaseToolHandler } from "./BaseToolHandler.js";
import { calendar_v3 } from 'googleapis';
import { RecurringEventHelpers, RecurringEventError, RECURRING_EVENT_ERRORS } from './RecurringEventHelpers.js';
import { ConflictDetectionService } from "../../services/conflict-detection/index.js";
import { createTimeObject } from "../utils/datetime.js";
import {
createStructuredResponse,
convertConflictsToStructured,
createWarningsArray
} from "../../utils/response-builder.js";
import {
UpdateEventResponse,
convertGoogleEventToStructured
} from "../../types/structured-responses.js";
export class UpdateEventHandler extends BaseToolHandler {
private conflictDetectionService: ConflictDetectionService;
constructor() {
super();
this.conflictDetectionService = new ConflictDetectionService();
}
async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
const validArgs = args as UpdateEventInput;
// Check for conflicts if enabled
let conflicts = null;
if (validArgs.checkConflicts !== false && (validArgs.start || validArgs.end)) {
// Get the existing event to merge with updates
const calendar = this.getCalendar(oauth2Client);
const existingEvent = await calendar.events.get({
calendarId: validArgs.calendarId,
eventId: validArgs.eventId
});
if (!existingEvent.data) {
throw new Error('Event not found');
}
// Create updated event object for conflict checking
const timezone = validArgs.timeZone || await this.getCalendarTimezone(oauth2Client, validArgs.calendarId);
const eventToCheck: calendar_v3.Schema$Event = {
...existingEvent.data,
id: validArgs.eventId,
summary: validArgs.summary || existingEvent.data.summary,
description: validArgs.description || existingEvent.data.description,
start: validArgs.start ? createTimeObject(validArgs.start, timezone) : existingEvent.data.start,
end: validArgs.end ? createTimeObject(validArgs.end, timezone) : existingEvent.data.end,
location: validArgs.location || existingEvent.data.location,
};
// Check for conflicts
conflicts = await this.conflictDetectionService.checkConflicts(
oauth2Client,
eventToCheck,
validArgs.calendarId,
{
checkDuplicates: false, // Don't check duplicates for updates
checkConflicts: true,
calendarsToCheck: validArgs.calendarsToCheck || [validArgs.calendarId]
}
);
}
// Update the event
const event = await this.updateEventWithScope(oauth2Client, validArgs);
// Create structured response
const response: UpdateEventResponse = {
event: convertGoogleEventToStructured(event, validArgs.calendarId)
};
// Add conflict information if present
if (conflicts && conflicts.hasConflicts) {
const structuredConflicts = convertConflictsToStructured(conflicts);
if (structuredConflicts.conflicts) {
response.conflicts = structuredConflicts.conflicts;
}
response.warnings = createWarningsArray(conflicts);
}
return createStructuredResponse(response);
}
private async updateEventWithScope(
client: OAuth2Client,
args: UpdateEventInput
): Promise<calendar_v3.Schema$Event> {
try {
const calendar = this.getCalendar(client);
const helpers = new RecurringEventHelpers(calendar);
// Get calendar's default timezone if not provided
const defaultTimeZone = await this.getCalendarTimezone(client, args.calendarId);
// Detect event type and validate scope usage
const eventType = await helpers.detectEventType(args.eventId, args.calendarId);
if (args.modificationScope && args.modificationScope !== 'all' && eventType !== 'recurring') {
throw new RecurringEventError(
'Scope other than "all" only applies to recurring events',
RECURRING_EVENT_ERRORS.NON_RECURRING_SCOPE
);
}
switch (args.modificationScope) {
case 'thisEventOnly':
return this.updateSingleInstance(helpers, args, defaultTimeZone);
case 'all':
case undefined:
return this.updateAllInstances(helpers, args, defaultTimeZone);
case 'thisAndFollowing':
return this.updateFutureInstances(helpers, args, defaultTimeZone);
default:
throw new RecurringEventError(
`Invalid modification scope: ${args.modificationScope}`,
RECURRING_EVENT_ERRORS.INVALID_SCOPE
);
}
} catch (error) {
if (error instanceof RecurringEventError) {
throw error;
}
throw this.handleGoogleApiError(error);
}
}
private async updateSingleInstance(
helpers: RecurringEventHelpers,
args: UpdateEventInput,
defaultTimeZone: string
): Promise<calendar_v3.Schema$Event> {
if (!args.originalStartTime) {
throw new RecurringEventError(
'originalStartTime is required for single instance updates',
RECURRING_EVENT_ERRORS.MISSING_ORIGINAL_TIME
);
}
const calendar = helpers.getCalendar();
const instanceId = helpers.formatInstanceId(args.eventId, args.originalStartTime);
const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
const conferenceDataVersion = requestBody.conferenceData !== undefined ? 1 : undefined;
const supportsAttachments = requestBody.attachments !== undefined ? true : undefined;
const response = await calendar.events.patch({
calendarId: args.calendarId,
eventId: instanceId,
requestBody,
...(conferenceDataVersion && { conferenceDataVersion }),
...(supportsAttachments && { supportsAttachments })
});
if (!response.data) throw new Error('Failed to update event instance');
return response.data;
}
private async updateAllInstances(
helpers: RecurringEventHelpers,
args: UpdateEventInput,
defaultTimeZone: string
): Promise<calendar_v3.Schema$Event> {
const calendar = helpers.getCalendar();
const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
const conferenceDataVersion = requestBody.conferenceData !== undefined ? 1 : undefined;
const supportsAttachments = requestBody.attachments !== undefined ? true : undefined;
const response = await calendar.events.patch({
calendarId: args.calendarId,
eventId: args.eventId,
requestBody,
...(conferenceDataVersion && { conferenceDataVersion }),
...(supportsAttachments && { supportsAttachments })
});
if (!response.data) throw new Error('Failed to update event');
return response.data;
}
private async updateFutureInstances(
helpers: RecurringEventHelpers,
args: UpdateEventInput,
defaultTimeZone: string
): Promise<calendar_v3.Schema$Event> {
if (!args.futureStartDate) {
throw new RecurringEventError(
'futureStartDate is required for future instance updates',
RECURRING_EVENT_ERRORS.MISSING_FUTURE_DATE
);
}
const calendar = helpers.getCalendar();
const effectiveTimeZone = args.timeZone || defaultTimeZone;
// 1. Get original event
const originalResponse = await calendar.events.get({
calendarId: args.calendarId,
eventId: args.eventId
});
const originalEvent = originalResponse.data;
if (!originalEvent.recurrence) {
throw new Error('Event does not have recurrence rules');
}
// 2. Calculate UNTIL date and update original event
const untilDate = helpers.calculateUntilDate(args.futureStartDate);
const updatedRecurrence = helpers.updateRecurrenceWithUntil(originalEvent.recurrence, untilDate);
await calendar.events.patch({
calendarId: args.calendarId,
eventId: args.eventId,
requestBody: { recurrence: updatedRecurrence }
});
// 3. Create new recurring event starting from future date
const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
// Calculate end time if start time is changing
let endTime = args.end;
if (args.start || args.futureStartDate) {
const newStartTime = args.start || args.futureStartDate;
endTime = endTime || helpers.calculateEndTime(newStartTime, originalEvent);
}
const newEvent = {
...helpers.cleanEventForDuplication(originalEvent),
...requestBody,
start: {
dateTime: args.start || args.futureStartDate,
timeZone: effectiveTimeZone
},
end: {
dateTime: endTime,
timeZone: effectiveTimeZone
}
};
const conferenceDataVersion = newEvent.conferenceData !== undefined ? 1 : undefined;
const supportsAttachments = newEvent.attachments !== undefined ? true : undefined;
const response = await calendar.events.insert({
calendarId: args.calendarId,
requestBody: newEvent,
...(conferenceDataVersion && { conferenceDataVersion }),
...(supportsAttachments && { supportsAttachments })
});
if (!response.data) throw new Error('Failed to create new recurring event');
return response.data;
}
}
```
--------------------------------------------------------------------------------
/src/handlers/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { calendar_v3 } from "googleapis";
import { ConflictCheckResult } from "../services/conflict-detection/types.js";
/**
* Generates a Google Calendar event view URL
*/
export function generateEventUrl(calendarId: string, eventId: string): string {
const encodedCalendarId = encodeURIComponent(calendarId);
const encodedEventId = encodeURIComponent(eventId);
return `https://calendar.google.com/calendar/event?eid=${encodedEventId}&cid=${encodedCalendarId}`;
}
/**
* Gets the URL for a calendar event
*/
export function getEventUrl(event: calendar_v3.Schema$Event, calendarId?: string): string | null {
if (event.htmlLink) {
return event.htmlLink;
} else if (calendarId && event.id) {
return generateEventUrl(calendarId, event.id);
}
return null;
}
/**
* Formats a date/time with timezone abbreviation
*/
function formatDateTime(dateTime?: string | null, date?: string | null, timeZone?: string): string {
if (!dateTime && !date) return "unspecified";
try {
const dt = dateTime || date;
if (!dt) return "unspecified";
// If it's a date-only event (all-day), handle it specially
if (date && !dateTime) {
// For all-day events, just format the date string directly
// Date-only strings like "2025-03-15" should be displayed as-is
const [year, month, day] = date.split('-').map(Number);
// Create a date string without any timezone conversion
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Calculate day of week using Zeller's congruence (timezone-independent)
const q = day;
const m = month <= 2 ? month + 12 : month;
const y = month <= 2 ? year - 1 : year;
const k = y % 100;
const j = Math.floor(y / 100);
const h = (q + Math.floor((13 * (m + 1)) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) - 2 * j) % 7;
const dayOfWeek = (h + 6) % 7; // Convert to 0=Sunday format
return `${dayNames[dayOfWeek]}, ${monthNames[month - 1]} ${day}, ${year}`;
}
const parsedDate = new Date(dt);
if (isNaN(parsedDate.getTime())) return dt;
// For timed events, include timezone
const options: Intl.DateTimeFormatOptions = {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
};
if (timeZone) {
options.timeZone = timeZone;
}
return parsedDate.toLocaleString('en-US', options);
} catch (error) {
return dateTime || date || "unspecified";
}
}
/**
* Formats attendees with their response status
*/
function formatAttendees(attendees?: calendar_v3.Schema$EventAttendee[]): string {
if (!attendees || attendees.length === 0) return "";
const formatted = attendees.map(attendee => {
const email = attendee.email || "unknown";
const name = attendee.displayName || email;
const status = attendee.responseStatus || "unknown";
const statusText = {
'accepted': 'accepted',
'declined': 'declined',
'tentative': 'tentative',
'needsAction': 'pending'
}[status] || 'unknown';
return `${name} (${statusText})`;
}).join(", ");
return `\nGuests: ${formatted}`;
}
/**
* Formats a single event with rich details
*/
export function formatEventWithDetails(event: calendar_v3.Schema$Event, calendarId?: string): string {
const title = event.summary ? `Event: ${event.summary}` : "Untitled Event";
const eventId = event.id ? `\nEvent ID: ${event.id}` : "";
const description = event.description ? `\nDescription: ${event.description}` : "";
const location = event.location ? `\nLocation: ${event.location}` : "";
const colorId = event.colorId ? `\nColor ID: ${event.colorId}` : "";
// Format start and end times with timezone
const startTime = formatDateTime(event.start?.dateTime, event.start?.date, event.start?.timeZone || undefined);
const endTime = formatDateTime(event.end?.dateTime, event.end?.date, event.end?.timeZone || undefined);
let timeInfo: string;
if (event.start?.date) {
// All-day event
if (event.start.date === event.end?.date) {
// Single day all-day event
timeInfo = `\nDate: ${startTime}`;
} else {
// Multi-day all-day event - end date is exclusive, so subtract 1 day for display
if (event.end?.date) {
// Parse the end date properly without timezone conversion
const [year, month, day] = event.end.date.split('-').map(Number);
// Subtract 1 day since end is exclusive, handling month/year boundaries
let adjustedDay = day - 1;
let adjustedMonth = month;
let adjustedYear = year;
if (adjustedDay < 1) {
adjustedMonth--;
if (adjustedMonth < 1) {
adjustedMonth = 12;
adjustedYear--;
}
// Get days in the previous month
const daysInMonth = new Date(adjustedYear, adjustedMonth, 0).getDate();
adjustedDay = daysInMonth;
}
// Format without using Date object to avoid timezone issues
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Calculate day of week using Zeller's congruence
const q = adjustedDay;
const m = adjustedMonth <= 2 ? adjustedMonth + 12 : adjustedMonth;
const y = adjustedMonth <= 2 ? adjustedYear - 1 : adjustedYear;
const k = y % 100;
const j = Math.floor(y / 100);
const h = (q + Math.floor((13 * (m + 1)) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) - 2 * j) % 7;
const dayOfWeek = (h + 6) % 7; // Convert to 0=Sunday format
const adjustedEndTime = `${dayNames[dayOfWeek]}, ${monthNames[adjustedMonth - 1]} ${adjustedDay}, ${adjustedYear}`;
timeInfo = `\nStart Date: ${startTime}\nEnd Date: ${adjustedEndTime}`;
} else {
timeInfo = `\nStart Date: ${startTime}`;
}
}
} else {
// Timed event
timeInfo = `\nStart: ${startTime}\nEnd: ${endTime}`;
}
const attendeeInfo = formatAttendees(event.attendees);
const eventUrl = getEventUrl(event, calendarId);
const urlInfo = eventUrl ? `\nView: ${eventUrl}` : "";
return `${title}${eventId}${description}${timeInfo}${location}${colorId}${attendeeInfo}${urlInfo}`;
}
/**
* Formats conflict check results for display
*/
export function formatConflictWarnings(conflicts: ConflictCheckResult): string {
if (!conflicts.hasConflicts) return "";
let warnings = "";
// Format duplicate warnings
if (conflicts.duplicates.length > 0) {
warnings += "\n\n⚠️ POTENTIAL DUPLICATES DETECTED:";
for (const dup of conflicts.duplicates) {
warnings += `\n\n━━━ Duplicate Event (${Math.round(dup.event.similarity * 100)}% similar) ━━━`;
warnings += `\n${dup.suggestion}`;
// Show full event details if available
if (dup.fullEvent) {
warnings += `\n\nExisting event details:`;
warnings += `\n${formatEventWithDetails(dup.fullEvent, dup.calendarId)}`;
} else {
// Fallback to basic info
warnings += `\n• "${dup.event.title}"`;
if (dup.event.url) {
warnings += `\n View existing event: ${dup.event.url}`;
}
}
}
}
// Format conflict warnings
if (conflicts.conflicts.length > 0) {
warnings += "\n\n⚠️ SCHEDULING CONFLICTS DETECTED:";
const conflictsByCalendar = conflicts.conflicts.reduce((acc, conflict) => {
if (!acc[conflict.calendar]) acc[conflict.calendar] = [];
acc[conflict.calendar].push(conflict);
return acc;
}, {} as Record<string, typeof conflicts.conflicts>);
for (const [calendar, calendarConflicts] of Object.entries(conflictsByCalendar)) {
warnings += `\n\nCalendar: ${calendar}`;
for (const conflict of calendarConflicts) {
warnings += `\n\n━━━ Conflicting Event ━━━`;
if (conflict.overlap) {
warnings += `\n⚠️ Overlap: ${conflict.overlap.duration} (${conflict.overlap.percentage}% of your event)`;
}
// Show full event details if available
if (conflict.fullEvent) {
warnings += `\n\nConflicting event details:`;
warnings += `\n${formatEventWithDetails(conflict.fullEvent, calendar)}`;
} else {
// Fallback to basic info
warnings += `\n• Conflicts with "${conflict.event.title}"`;
if (conflict.event.start && conflict.event.end) {
const start = formatDateTime(conflict.event.start);
const end = formatDateTime(conflict.event.end);
warnings += `\n Time: ${start} - ${end}`;
}
if (conflict.event.url) {
warnings += `\n View event: ${conflict.event.url}`;
}
}
}
}
}
return warnings;
}
/**
* Creates a response with event details and optional conflict warnings
*/
export function createEventResponseWithConflicts(
event: calendar_v3.Schema$Event,
calendarId: string,
conflicts?: ConflictCheckResult,
actionVerb: string = "created"
): string {
const eventDetails = formatEventWithDetails(event, calendarId);
const conflictWarnings = conflicts ? formatConflictWarnings(conflicts) : "";
const successMessage = conflicts?.hasConflicts
? `Event ${actionVerb} with warnings!`
: `Event ${actionVerb} successfully!`;
return `${successMessage}\n\n${eventDetails}${conflictWarnings}`;
}
```
--------------------------------------------------------------------------------
/src/tests/unit/schemas/schema-compatibility.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from 'vitest';
import { ToolRegistry } from '../../../tools/registry.js';
/**
* Provider-Specific Schema Compatibility Tests
*
* These tests ensure that schemas are compatible with different MCP clients
* by testing what each provider actually receives, not internal implementation.
*
* - OpenAI: Receives converted schemas (anyOf flattened to string)
* - Python MCP: Receives raw schemas (anyOf preserved for native array support)
* - Claude: Uses raw MCP schemas
*/
// Type for JSON Schema objects (subset of what zod-to-json-schema returns)
interface JSONSchemaObject {
type?: string;
properties?: Record<string, any>;
required?: string[];
anyOf?: any[];
[key: string]: any;
}
describe('Provider-Specific Schema Compatibility', () => {
describe('OpenAI Schema Compatibility', () => {
// Helper function that mimics OpenAI schema conversion from openai-mcp-integration.test.ts
const convertMCPSchemaToOpenAI = (mcpSchema: any): any => {
if (!mcpSchema) {
return {
type: 'object',
properties: {},
required: []
};
}
return {
type: 'object',
properties: enhancePropertiesForOpenAI(mcpSchema.properties || {}),
required: mcpSchema.required || []
};
};
const enhancePropertiesForOpenAI = (properties: any): any => {
const enhanced: any = {};
for (const [key, value] of Object.entries(properties)) {
const prop = value as any;
enhanced[key] = { ...prop };
// Handle anyOf union types (OpenAI doesn't support these well)
if (prop.anyOf && Array.isArray(prop.anyOf)) {
const stringType = prop.anyOf.find((t: any) => t.type === 'string');
if (stringType) {
enhanced[key] = {
type: 'string',
description: `${stringType.description || prop.description || ''} Note: For multiple values, use JSON array string format: '["id1", "id2"]'`.trim()
};
} else {
enhanced[key] = { ...prop.anyOf[0] };
}
delete enhanced[key].anyOf;
}
// Recursively enhance nested objects
if (enhanced[key].type === 'object' && enhanced[key].properties) {
enhanced[key].properties = enhancePropertiesForOpenAI(enhanced[key].properties);
}
// Enhance array items if they contain objects
if (enhanced[key].type === 'array' && enhanced[key].items && enhanced[key].items.properties) {
enhanced[key].items = {
...enhanced[key].items,
properties: enhancePropertiesForOpenAI(enhanced[key].items.properties)
};
}
}
return enhanced;
};
it('should ensure ALL tools (including list-events) have no problematic features after OpenAI conversion', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const problematicFeatures = ['oneOf', 'anyOf', 'allOf', 'not'];
const issues: string[] = [];
for (const tool of tools) {
// Convert to OpenAI format (this is what OpenAI actually sees)
const openaiSchema = convertMCPSchemaToOpenAI(tool.inputSchema);
const schemaStr = JSON.stringify(openaiSchema);
for (const feature of problematicFeatures) {
if (schemaStr.includes(`"${feature}"`)) {
issues.push(`Tool "${tool.name}" contains "${feature}" after OpenAI conversion - this will break OpenAI function calling`);
}
}
}
if (issues.length > 0) {
throw new Error(`OpenAI schema compatibility issues found:\n${issues.join('\n')}`);
}
});
it('should convert list-events calendarId anyOf to string for OpenAI', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const listEventsTool = tools.find(t => t.name === 'list-events');
expect(listEventsTool).toBeDefined();
// Convert to OpenAI format
const openaiSchema = convertMCPSchemaToOpenAI(listEventsTool!.inputSchema);
// OpenAI should see a simple string type, not anyOf
expect(openaiSchema.properties.calendarId.type).toBe('string');
expect(openaiSchema.properties.calendarId.anyOf).toBeUndefined();
// Description should mention JSON array format
expect(openaiSchema.properties.calendarId.description).toContain('JSON array string format');
expect(openaiSchema.properties.calendarId.description).toMatch(/\[".*"\]/);
});
it('should ensure all converted schemas are valid objects', () => {
const tools = ToolRegistry.getToolsWithSchemas();
for (const tool of tools) {
const openaiSchema = convertMCPSchemaToOpenAI(tool.inputSchema);
expect(openaiSchema.type).toBe('object');
expect(openaiSchema.properties).toBeDefined();
expect(openaiSchema.required).toBeDefined();
}
});
});
describe('Python MCP Client Compatibility', () => {
it('should ensure list-events supports native arrays via anyOf', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const listEventsTool = tools.find(t => t.name === 'list-events');
expect(listEventsTool).toBeDefined();
// Raw MCP schema should have anyOf for Python clients
const schema = listEventsTool!.inputSchema as JSONSchemaObject;
expect(schema.properties).toBeDefined();
const calendarIdProp = schema.properties!.calendarId;
expect(calendarIdProp.anyOf).toBeDefined();
expect(Array.isArray(calendarIdProp.anyOf)).toBe(true);
expect(calendarIdProp.anyOf.length).toBe(2);
// Verify it has both string and array options
const types = calendarIdProp.anyOf.map((t: any) => t.type);
expect(types).toContain('string');
expect(types).toContain('array');
});
it('should ensure all other tools do NOT use anyOf/oneOf/allOf', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const problematicFeatures = ['oneOf', 'anyOf', 'allOf', 'not'];
const issues: string[] = [];
for (const tool of tools) {
// Skip list-events - it's explicitly allowed to use anyOf
if (tool.name === 'list-events') {
continue;
}
const schemaStr = JSON.stringify(tool.inputSchema);
for (const feature of problematicFeatures) {
if (schemaStr.includes(`"${feature}"`)) {
issues.push(`Tool "${tool.name}" contains problematic feature: ${feature}`);
}
}
}
if (issues.length > 0) {
throw new Error(`Raw MCP schema compatibility issues found:\n${issues.join('\n')}`);
}
});
});
describe('General Schema Structure', () => {
it('should have tools available', () => {
const tools = ToolRegistry.getToolsWithSchemas();
expect(tools).toBeDefined();
expect(tools.length).toBeGreaterThan(0);
});
it('should have proper schema structure for all tools', () => {
const tools = ToolRegistry.getToolsWithSchemas();
expect(tools).toBeDefined();
expect(tools.length).toBeGreaterThan(0);
for (const tool of tools) {
const schema = tool.inputSchema as JSONSchemaObject;
// All schemas should be objects at the top level
expect(schema.type).toBe('object');
}
});
it('should validate specific known tool schemas exist', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const toolSchemas = new Map();
for (const tool of tools) {
toolSchemas.set(tool.name, tool.inputSchema);
}
// Validate that key tools exist and have the proper basic structure
const listEventsSchema = toolSchemas.get('list-events') as JSONSchemaObject;
expect(listEventsSchema).toBeDefined();
expect(listEventsSchema.type).toBe('object');
if (listEventsSchema.properties) {
expect(listEventsSchema.properties.calendarId).toBeDefined();
expect(listEventsSchema.properties.timeMin).toBeDefined();
expect(listEventsSchema.properties.timeMax).toBeDefined();
}
// Check other important tools exist
expect(toolSchemas.get('create-event')).toBeDefined();
expect(toolSchemas.get('update-event')).toBeDefined();
expect(toolSchemas.get('delete-event')).toBeDefined();
});
it('should test that all datetime fields have proper format', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const toolsWithDateTimeFields = ['list-events', 'search-events', 'create-event', 'update-event', 'get-freebusy'];
for (const tool of tools) {
if (toolsWithDateTimeFields.includes(tool.name)) {
// These tools should exist and be properly typed
const schema = tool.inputSchema as JSONSchemaObject;
expect(schema.type).toBe('object');
}
}
});
it('should ensure enum fields are properly structured', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const toolsWithEnums = ['update-event', 'delete-event'];
for (const tool of tools) {
if (toolsWithEnums.includes(tool.name)) {
// These tools should exist and be properly typed
const schema = tool.inputSchema as JSONSchemaObject;
expect(schema.type).toBe('object');
}
}
});
it('should validate array fields have proper items definition', () => {
const tools = ToolRegistry.getToolsWithSchemas();
const toolsWithArrays = ['create-event', 'update-event', 'get-freebusy'];
for (const tool of tools) {
if (toolsWithArrays.includes(tool.name)) {
// These tools should exist and be properly typed
const schema = tool.inputSchema as JSONSchemaObject;
expect(schema.type).toBe('object');
}
}
});
});
});
/**
* Schema Validation Rules Documentation
*
* This test documents the rules that our schemas must follow
* to be compatible with various MCP clients.
*/
describe('Schema Validation Rules Documentation', () => {
it('should document provider-specific compatibility requirements', () => {
const rules = {
'OpenAI': 'Schemas are converted to remove anyOf/oneOf/allOf. Union types flattened to primary type with usage notes in description.',
'Python MCP': 'Native array support via anyOf for list-events.calendarId. Accepts both string and array types directly.',
'Claude/Generic MCP': 'Uses raw schemas. list-events has anyOf for flexibility, but most tools avoid union types for broad compatibility.',
'Top-level schema': 'All schemas must be type: "object" at root level.',
'DateTime fields': 'Support both RFC3339 with timezone and timezone-naive formats.',
'Array fields': 'Must have items schema defined for proper validation.',
'Enum fields': 'Must include type information alongside enum values.'
};
// This test documents the rules - it always passes but serves as documentation
expect(Object.keys(rules).length).toBeGreaterThan(0);
});
});
```
--------------------------------------------------------------------------------
/src/auth/server.ts:
--------------------------------------------------------------------------------
```typescript
import { OAuth2Client } from 'google-auth-library';
import { TokenManager } from './tokenManager.js';
import http from 'http';
import { URL } from 'url';
import open from 'open';
import { loadCredentials } from './client.js';
import { getAccountMode } from './utils.js';
export class AuthServer {
private baseOAuth2Client: OAuth2Client; // Used by TokenManager for validation/refresh
private flowOAuth2Client: OAuth2Client | null = null; // Used specifically for the auth code flow
private server: http.Server | null = null;
private tokenManager: TokenManager;
private portRange: { start: number; end: number };
private activeConnections: Set<import('net').Socket> = new Set(); // Track active socket connections
public authCompletedSuccessfully = false; // Flag for standalone script
constructor(oauth2Client: OAuth2Client) {
this.baseOAuth2Client = oauth2Client;
this.tokenManager = new TokenManager(oauth2Client);
this.portRange = { start: 3500, end: 3505 };
}
private createServer(): http.Server {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
if (url.pathname === '/') {
// Root route - show auth link
const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client;
const scopes = ['https://www.googleapis.com/auth/calendar'];
const authUrl = clientForUrl.generateAuthUrl({
access_type: 'offline',
scope: scopes,
prompt: 'consent'
});
const accountMode = getAccountMode();
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<h1>Google Calendar Authentication</h1>
<p><strong>Account Mode:</strong> <code>${accountMode}</code></p>
<p>You are authenticating for the <strong>${accountMode}</strong> account.</p>
<a href="${authUrl}">Authenticate with Google</a>
`);
} else if (url.pathname === '/oauth2callback') {
// OAuth callback route
const code = url.searchParams.get('code');
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Authorization code missing');
return;
}
if (!this.flowOAuth2Client) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Authentication flow not properly initiated.');
return;
}
try {
const { tokens } = await this.flowOAuth2Client.getToken(code);
await this.tokenManager.saveTokens(tokens);
this.authCompletedSuccessfully = true;
const tokenPath = this.tokenManager.getTokenPath();
const accountMode = this.tokenManager.getAccountMode();
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication Successful</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; }
.container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #4CAF50; }
p { color: #333; margin-bottom: 0.5em; }
code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
.account-mode { background-color: #e3f2fd; padding: 1em; border-radius: 5px; margin: 1em 0; }
</style>
</head>
<body>
<div class="container">
<h1>Authentication Successful!</h1>
<div class="account-mode">
<p><strong>Account Mode:</strong> <code>${accountMode}</code></p>
<p>Your authentication tokens have been saved for the <strong>${accountMode}</strong> account.</p>
</div>
<p>Tokens saved to:</p>
<p><code>${tokenPath}</code></p>
<p>You can now close this browser window.</p>
</div>
</body>
</html>
`);
} catch (error: unknown) {
this.authCompletedSuccessfully = false;
const message = error instanceof Error ? error.message : 'Unknown error';
process.stderr.write(`✗ Token save failed: ${message}\n`);
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication Failed</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; }
.container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #F44336; }
p { color: #333; }
</style>
</head>
<body>
<div class="container">
<h1>Authentication Failed</h1>
<p>An error occurred during authentication:</p>
<p><code>${message}</code></p>
<p>Please try again or check the server logs.</p>
</div>
</body>
</html>
`);
}
} else {
// 404 for other routes
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
// Track connections at server level
server.on('connection', (socket) => {
this.activeConnections.add(socket);
socket.on('close', () => {
this.activeConnections.delete(socket);
});
});
return server;
}
async start(openBrowser = true): Promise<boolean> {
// Add timeout wrapper to prevent hanging
return Promise.race([
this.startWithTimeout(openBrowser),
new Promise<boolean>((_, reject) => {
setTimeout(() => reject(new Error('Auth server start timed out after 10 seconds')), 10000);
})
]).catch(() => false); // Return false on timeout instead of throwing
}
private async startWithTimeout(openBrowser = true): Promise<boolean> {
if (await this.tokenManager.validateTokens()) {
this.authCompletedSuccessfully = true;
return true;
}
// Try to start the server and get the port
const port = await this.startServerOnAvailablePort();
if (port === null) {
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`);
this.authCompletedSuccessfully = false;
return false;
}
// Successfully started server on `port`. Now create the flow-specific OAuth client.
try {
const { client_id, client_secret } = await loadCredentials();
this.flowOAuth2Client = new OAuth2Client(
client_id,
client_secret,
`http://localhost:${port}/oauth2callback`
);
} catch (error) {
// Could not load credentials, cannot proceed with auth flow
this.authCompletedSuccessfully = false;
await this.stop(); // Stop the server we just started
return false;
}
// Generate Auth URL using the newly created flow client
const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/calendar'],
prompt: 'consent'
});
// Always show the URL in console for easy access
process.stderr.write(`\n🔗 Authentication URL: ${authorizeUrl}\n\n`);
process.stderr.write(`Or visit: http://localhost:${port}\n\n`);
if (openBrowser) {
try {
await open(authorizeUrl);
process.stderr.write(`Browser opened automatically. If it didn't open, use the URL above.\n`);
} catch (error) {
process.stderr.write(`Could not open browser automatically. Please use the URL above.\n`);
}
} else {
process.stderr.write(`Please visit the URL above to complete authentication.\n`);
}
return true; // Auth flow initiated
}
private async startServerOnAvailablePort(): Promise<number | null> {
for (let port = this.portRange.start; port <= this.portRange.end; port++) {
try {
await new Promise<void>((resolve, reject) => {
const testServer = this.createServer();
testServer.listen(port, () => {
this.server = testServer; // Assign to class property *only* if successful
resolve();
});
testServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
// Port is in use, close the test server and reject
testServer.close(() => reject(err));
} else {
// Other error, reject
reject(err);
}
});
});
return port; // Port successfully bound
} catch (error: unknown) {
// Check if it's EADDRINUSE, otherwise rethrow or handle
if (!(error instanceof Error && 'code' in error && error.code === 'EADDRINUSE')) {
// An unexpected error occurred during server start
return null;
}
// EADDRINUSE occurred, loop continues
}
}
return null; // No port found
}
public getRunningPort(): number | null {
if (this.server) {
const address = this.server.address();
if (typeof address === 'object' && address !== null) {
return address.port;
}
}
return null;
}
async stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server) {
// Force close all active connections
for (const connection of this.activeConnections) {
connection.destroy();
}
this.activeConnections.clear();
// Add a timeout to force close if server doesn't close gracefully
const timeout = setTimeout(() => {
process.stderr.write('Server close timeout, forcing exit...\n');
this.server = null;
resolve();
}, 2000); // 2 second timeout
this.server.close((err) => {
clearTimeout(timeout);
if (err) {
reject(err);
} else {
this.server = null;
resolve();
}
});
} else {
resolve();
}
});
}
}
```