Skip to main content
Glama
FreeBusyEventHandler.ts8.05 kB
import { BaseToolHandler } from './BaseToolHandler.js'; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { OAuth2Client } from "google-auth-library"; import { GetFreeBusyInput } from "../../tools/registry.js"; import { FreeBusyResponse as GoogleFreeBusyResponse } from '../../schemas/types.js'; import { FreeBusyResponse, BusySlot } from '../../types/structured-responses.js'; import { createStructuredResponse } from '../../utils/response-builder.js'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { convertToRFC3339 } from '../utils/datetime.js'; interface FreeBusyCalendarResult { busy: BusySlot[]; errors?: Array<{ domain?: string; reason?: string }>; } export class FreeBusyEventHandler extends BaseToolHandler { async runTool(args: any, accounts: Map<string, OAuth2Client>): Promise<CallToolResult> { const validArgs = args as GetFreeBusyInput; if (!this.isLessThanThreeMonths(validArgs.timeMin, validArgs.timeMax)) { throw new McpError( ErrorCode.InvalidRequest, "The time gap between timeMin and timeMax must be less than 3 months" ); } // Get clients for specified accounts (or all if not specified) const selectedAccounts = this.getClientsForAccounts(args.account, accounts); // Query freebusy from all selected accounts and merge results const mergedCalendars = await this.queryFreeBusyMultiAccount(selectedAccounts, validArgs); const response: FreeBusyResponse = { timeMin: validArgs.timeMin, timeMax: validArgs.timeMax, calendars: mergedCalendars }; return createStructuredResponse(response); } private async queryFreeBusyMultiAccount( accounts: Map<string, OAuth2Client>, args: GetFreeBusyInput ): Promise<Record<string, FreeBusyCalendarResult>> { const mergedCalendars: Record<string, FreeBusyCalendarResult> = {}; const calendarIds = args.calendars.map(c => c.id); // For multi-account queries, pre-resolve which calendars exist on which accounts // This prevents the "cartesian product" problem where we try to query all calendars // from all accounts, causing failures when a calendar doesn't exist on an account let accountCalendarMap: Map<string, string[]>; const resolutionWarnings: string[] = []; if (accounts.size > 1) { const { resolved, warnings } = await this.calendarRegistry.resolveCalendarsToAccounts( calendarIds, accounts ); accountCalendarMap = resolved; resolutionWarnings.push(...warnings); // If no calendars could be resolved, mark all as not found if (accountCalendarMap.size === 0) { for (const calId of calendarIds) { mergedCalendars[calId] = { busy: [], errors: [{ reason: 'notFound' }] }; } return mergedCalendars; } } else { // Single account: send all calendars to that account const [accountId] = accounts.keys(); accountCalendarMap = new Map([[accountId, calendarIds]]); } // Query from each account with only the calendars that exist on that account const results = await Promise.all( Array.from(accountCalendarMap.entries()).map(async ([accountId, calendarsForAccount]) => { const client = accounts.get(accountId)!; try { // Filter args.calendars to only include those routed to this account const filteredArgs: GetFreeBusyInput = { ...args, calendars: args.calendars.filter(c => calendarsForAccount.includes(c.id)) }; const result = await this.queryFreeBusy(client, filteredArgs); return { accountId, result, error: null, calendarsQueried: calendarsForAccount }; } catch (error) { // Log but don't fail - other accounts might succeed const message = error instanceof Error ? error.message : String(error); process.stderr.write(`Warning: FreeBusy query failed for account "${accountId}": ${message}\n`); return { accountId, result: null, error: message, calendarsQueried: calendarsForAccount }; } }) ); // Merge results from all accounts // For each calendar, prefer results without errors for (const calId of calendarIds) { let bestResult: FreeBusyCalendarResult | null = null; for (const { result } of results) { if (!result?.calendars) continue; const calData = result.calendars[calId]; if (!calData) continue; // If we don't have a result yet, or this one has no errors but previous did, use this one if (!bestResult) { bestResult = { busy: calData.busy?.map((slot: any) => ({ start: slot.start, end: slot.end })) || [], errors: calData.errors?.map((err: any) => ({ domain: err.domain, reason: err.reason })) }; } else if (bestResult.errors && !calData.errors) { // Current best has errors but this one doesn't - prefer this one bestResult = { busy: calData.busy?.map((slot: any) => ({ start: slot.start, end: slot.end })) || [] }; } } // If no account returned data for this calendar, mark it as not found if (!bestResult) { mergedCalendars[calId] = { busy: [], errors: [{ reason: 'notFound' }] }; } else { mergedCalendars[calId] = bestResult; } } return mergedCalendars; } private async queryFreeBusy( client: OAuth2Client, args: GetFreeBusyInput ): Promise<GoogleFreeBusyResponse> { try { const calendar = this.getCalendar(client); // Determine timezone with correct precedence: // 1. Explicit timeZone parameter (highest priority) // 2. Primary calendar's default timezone (fallback) // 3. UTC if calendar timezone retrieval fails let timezone: string; if (args.timeZone) { timezone = args.timeZone; } else { try { timezone = await this.getCalendarTimezone(client, 'primary'); } catch (error) { // If we can't get the primary calendar's timezone, fall back to UTC // This can happen if the user doesn't have access to 'primary' calendar timezone = 'UTC'; } } // Convert time boundaries to RFC3339 format for Google Calendar API // This handles both timezone-aware and timezone-naive datetime strings const timeMin = convertToRFC3339(args.timeMin, timezone); const timeMax = convertToRFC3339(args.timeMax, timezone); // Build request body // Note: The timeZone parameter affects the response format, not request interpretation // Since timeMin/timeMax are in RFC3339 (with timezone), they're unambiguous // But we include timeZone so busy periods in the response use consistent timezone const requestBody: any = { timeMin, timeMax, items: args.calendars, timeZone: timezone, // Always include to ensure response consistency }; // Only add optional expansion fields if provided if (args.groupExpansionMax !== undefined) { requestBody.groupExpansionMax = args.groupExpansionMax; } if (args.calendarExpansionMax !== undefined) { requestBody.calendarExpansionMax = args.calendarExpansionMax; } const response = await calendar.freebusy.query({ requestBody, }); return response.data as GoogleFreeBusyResponse; } catch (error) { throw this.handleGoogleApiError(error); } } private isLessThanThreeMonths(timeMin: string, timeMax: string): boolean { const minDate = new Date(timeMin); const maxDate = new Date(timeMax); const diffInMilliseconds = maxDate.getTime() - minDate.getTime(); const threeMonthsInMilliseconds = 3 * 30 * 24 * 60 * 60 * 1000; return diffInMilliseconds <= threeMonthsInMilliseconds; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nspady/google-calendar-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server