import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; import { EwsClient, loadConfig } from "../ews_client.ts"; export function registerAvailabilityTools(server: McpServer): void { server.registerTool( "find_free_time", { description: "Find free time slots in your own calendar", inputSchema: z.object({ startDate: z.string().describe("Start date in YYYY-MM-DD format"), endDate: z.string().optional().describe( "End date in YYYY-MM-DD format (defaults to startDate)", ), durationMinutes: z.number().default(30).describe( "Minimum slot duration in minutes (default 30)", ), startHour: z.number().default(9).describe( "Working day start hour (0-23, default 9)", ), endHour: z.number().default(18).describe( "Working day end hour (0-23, default 18)", ), }), }, async ({ startDate, endDate, durationMinutes, startHour, endHour }) => { try { const client = new EwsClient(loadConfig()); const config = loadConfig(); const ed = endDate || startDate; const data = await client.getUserAvailability( [config.email], startDate, ed, ); const body = data?.Envelope?.Body?.GetUserAvailabilityResponse ?? data?.Envelope?.Body ?? {}; const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse ?? []; const responses = Array.isArray(freeBusyArray) ? freeBusyArray : [freeBusyArray]; const busyPeriods: { start: Date; end: Date }[] = []; for (const fbResp of responses) { const fbView = fbResp?.FreeBusyView ?? {}; const calEvents = fbView?.CalendarEventArray?.CalendarEvent ?? []; const evList = Array.isArray(calEvents) ? calEvents : [calEvents]; for (const ev of evList) { const bt = ev.BusyType ?? ""; if (bt === "Free" || bt === "NoData") continue; const startStr = ev.StartTime ?? ""; const endStr = ev.EndTime ?? ""; if (startStr && endStr) { busyPeriods.push({ start: new Date(startStr), end: new Date(endStr), }); } } } const merged = mergeBusyPeriods(busyPeriods); const freeSlots = findFreeSlots( merged, new Date(startDate), ed ? new Date(ed) : new Date(startDate), startHour, endHour, durationMinutes, ); return { content: [{ type: "text" as const, text: JSON.stringify({ freeSlots }), }], }; } catch (error: any) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }), }], }; } }, ); server.registerTool( "find_meeting_time", { description: "Find meeting times that work for multiple people", inputSchema: z.object({ emails: z.string().describe( "Comma-separated email addresses of attendees", ), startDate: z.string().describe("Start date in YYYY-MM-DD format"), endDate: z.string().optional().describe( "End date in YYYY-MM-DD format (defaults to startDate)", ), durationMinutes: z.number().default(30).describe( "Minimum slot duration in minutes (default 30)", ), startHour: z.number().default(9).describe( "Working day start hour (0-23, default 9)", ), endHour: z.number().default(18).describe( "Working day end hour (0-23, default 18)", ), }), }, async ( { emails, startDate, endDate, durationMinutes, startHour, endHour }, ) => { try { const client = new EwsClient(loadConfig()); const emailList = emails.split(",").map((e) => e.trim()).filter( Boolean, ); const ed = endDate || startDate; const data = await client.getUserAvailability(emailList, startDate, ed); const body = data?.Envelope?.Body?.GetUserAvailabilityResponse ?? data?.Envelope?.Body ?? {}; const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse ?? []; const responses = Array.isArray(freeBusyArray) ? freeBusyArray : [freeBusyArray]; const allBusy: { start: Date; end: Date }[] = []; const attendeeInfo: any[] = []; for (let i = 0; i < responses.length; i++) { const fbResp = responses[i]; const fbView = fbResp?.FreeBusyView ?? {}; const email = emailList[i] || `Person ${i + 1}`; const mergedFb = fbView.MergedFreeBusy ?? ""; if (mergedFb) { const startTime = new Date(`${startDate}T00:00:00`); const busyPeriods = parseFreeBusyString(mergedFb, startTime); const busyCount = busyPeriods.length; const freeCount = mergedFb.split("").filter((c: string) => c === "0" ).length; attendeeInfo.push({ email, busySlots: busyCount, freeSlots: freeCount, }); allBusy.push(...busyPeriods); } else { const calEvents = fbView?.CalendarEventArray?.CalendarEvent ?? []; const evList = Array.isArray(calEvents) ? calEvents : [calEvents]; attendeeInfo.push({ email, calendarEvents: evList.length }); for (const ev of evList) { const startStr = ev.StartTime ?? ""; const endStr = ev.EndTime ?? ""; if (startStr && endStr) { allBusy.push({ start: new Date(startStr), end: new Date(endStr), }); } } } } const merged = mergeBusyPeriods(allBusy); const freeByDate = findFreeSlots( merged, new Date(startDate), new Date(ed), startHour, endHour, durationMinutes, ); return { content: [{ type: "text" as const, text: JSON.stringify({ period: { start: startDate, end: ed }, attendees: attendeeInfo, freeSlots: freeByDate, }), }], }; } catch (error: any) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }), }], }; } }, ); } // ── Helpers ────────────────────────────────────────────────────────────── function parseFreeBusyString( fbStr: string, startTime: Date, intervalMinutes: number = 30, ): { start: Date; end: Date }[] { const periods: { start: Date; end: Date }[] = []; const current = new Date(startTime); for (const char of fbStr) { const next = new Date(current.getTime() + intervalMinutes * 60000); if (char !== "0") { periods.push({ start: new Date(current), end: next }); } current.setTime(next.getTime()); } return periods; } function mergeBusyPeriods( periods: { start: Date; end: Date }[], ): { start: Date; end: Date }[] { if (!periods.length) return []; const sorted = [...periods].sort((a, b) => a.start.getTime() - b.start.getTime() ); const merged: { start: Date; end: Date }[] = [{ ...sorted[0] }]; for (let i = 1; i < sorted.length; i++) { const last = merged[merged.length - 1]; if (sorted[i].start <= last.end) { last.end = sorted[i].end > last.end ? sorted[i].end : last.end; } else { merged.push({ ...sorted[i] }); } } return merged; } function findFreeSlots( busyPeriods: { start: Date; end: Date }[], startDate: Date, endDate: Date, startHour: number, endHour: number, durationMinutes: number, ): Record { const result: Record< string, { start: string; end: string; durationMinutes: number }[] > = {}; const current = new Date(startDate); current.setDate(current.getDate()); current.setHours(0, 0, 0, 0); const end = new Date(endDate); end.setDate(end.getDate() + 1); end.setHours(0, 0, 0, 0); while (current < end) { if (current.getDay() !== 0 && current.getDay() !== 6) { const dayStart = new Date(current); dayStart.setHours(startHour, 0, 0, 0); const dayEnd = new Date(current); dayEnd.setHours(endHour, 0, 0, 0); const dayBusy = busyPeriods .filter((p) => p.start < dayEnd && p.end > dayStart) .map((p) => ({ start: p.start < dayStart ? dayStart : p.start, end: p.end > dayEnd ? dayEnd : p.end, })); const merged = mergeBusyPeriods(dayBusy); const slots: { start: string; end: string; durationMinutes: number }[] = []; let cursor = new Date(dayStart); for (const bp of merged) { if (cursor < bp.start) { const gap = (bp.start.getTime() - cursor.getTime()) / 60000; if (gap >= durationMinutes) { slots.push({ start: formatTime(cursor), end: formatTime(bp.start), durationMinutes: gap, }); } } cursor = bp.end > cursor ? bp.end : cursor; } if (cursor < dayEnd) { const gap = (dayEnd.getTime() - cursor.getTime()) / 60000; if (gap >= durationMinutes) { slots.push({ start: formatTime(cursor), end: formatTime(dayEnd), durationMinutes: gap, }); } } if (slots.length) { result[current.toISOString().slice(0, 10)] = slots; } } current.setDate(current.getDate() + 1); } return result; } function formatTime(d: Date): string { return d.toTimeString().slice(0, 5); }