diff --git a/ews_client.ts b/ews_client.ts index dca9434..84415ef 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -523,4 +523,185 @@ ${restriction} text = text.replace(/[ \t]+/g, " "); return text.trim(); } + + // ── UpdateItem (for marking read/unread, etc.) ────────────────────────── + + async updateItems( + itemIds: string[], + updates: { fieldUri: string; value: string | boolean }[], + ): Promise { + const propName = (fieldUri: string): string => { + const parts = fieldUri.split(":"); + return parts[parts.length - 1]; + }; + + const changesXml = itemIds.map((id) => { + const updatesXml = updates.map((u) => { + const val = typeof u.value === "boolean" ? (u.value ? "true" : "false") : u.value; + return `\ + + + + ${String(val)} + + `; + }).join("\n"); + + return `\ + + + +${updatesXml} + + `; + }).join("\n"); + + const soap = buildSoapEnvelope(`\ + + +${changesXml} + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/UpdateItem", + ); + + return this.extractResponseMessages(data); + } + + // ── GetAttachment ─────────────────────────────────────────────────────── + + async getAttachment(attachmentId: string): Promise<{ content: Buffer; name: string; contentType: string }> { + const soap = buildSoapEnvelope(`\ + + + + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/GetAttachment", + ); + + for (const msg of this.extractResponseMessages(data)) { + const attachments = msg?.Attachments?.FileAttachment; + if (!attachments) continue; + const list = Array.isArray(attachments) ? attachments : [attachments]; + for (const a of list) { + const contentBase64 = a.Content ?? ""; + const content = Buffer.from(contentBase64, "base64"); + return { + content, + name: a.Name ?? "attachment", + contentType: a.ContentType ?? "application/octet-stream", + }; + } + } + + throw new Error(`Attachment '${attachmentId}' not found`); + } + + // ── FindItem with CalendarView ────────────────────────────────────────── + + async findCalendarItems( + folderId: string, + startDate: string, + endDate: string, + ): Promise { + const soap = buildSoapEnvelope(`\ + + + AllProperties + HTML + + + + + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/FindItem", + ); + + for (const msg of this.extractResponseMessages(data)) { + const items = msg?.RootFolder?.Items?.CalendarItem; + if (!items) continue; + return Array.isArray(items) ? items : [items]; + } + + return []; + } + + // ── GetUserAvailability ───────────────────────────────────────────────── + + async getUserAvailability( + emails: string[], + startDate: string, + endDate: string, + requestedView: string = "DetailedMerged", + ): Promise { + const mailboxDataXml = emails.map((email) => `\ + + + ${email.replace(/&/g, "&").replace(/ + + Required + `).join("\n"); + + const soap = ` + + + + +${mailboxDataXml} + + + + ${startDate}T00:00:00 + ${endDate}T23:59:59 + + 30 + ${requestedView} + + + +`; + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/GetUserAvailability", + ); + + return data; + } + + // ── ResolveNames (directory search) ───────────────────────────────────── + + async resolveNames(query: string, fullContact: boolean = true): Promise { + const soap = buildSoapEnvelope(`\ + + ${query.replace(/&/g, "&").replace(/ + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/ResolveNames", + ); + + for (const msg of this.extractResponseMessages(data)) { + const resolutions = msg?.ResolutionSet?.Resolution; + if (resolutions) { + return Array.isArray(resolutions) ? resolutions : [resolutions]; + } + } + + return []; + } } diff --git a/main.ts b/main.ts index de1f50d..097250e 100644 --- a/main.ts +++ b/main.ts @@ -8,6 +8,9 @@ import { initDataDir } from "./ews_client.ts"; import { registerAuthTools } from "./tools/auth.ts"; import { registerEmailTools } from "./tools/email.ts"; import { registerFolderTools } from "./tools/folders.ts"; +import { registerCalendarTools } from "./tools/calendar.ts"; +import { registerAvailabilityTools } from "./tools/availability.ts"; +import { registerPeopleTools } from "./tools/people.ts"; const program = new Command() .name("exchange-mcp") @@ -35,6 +38,9 @@ function buildServer(): McpServer { registerAuthTools(server); registerEmailTools(server); registerFolderTools(server); + registerCalendarTools(server); + registerAvailabilityTools(server); + registerPeopleTools(server); return server; } diff --git a/tools/availability.ts b/tools/availability.ts new file mode 100644 index 0000000..890fdc9 --- /dev/null +++ b/tools/availability.ts @@ -0,0 +1,281 @@ +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 = {}; + + 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); +} diff --git a/tools/calendar.ts b/tools/calendar.ts new file mode 100644 index 0000000..84883a9 --- /dev/null +++ b/tools/calendar.ts @@ -0,0 +1,173 @@ +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod/v4"; +import fs from "node:fs"; +import path from "node:path"; +import { EwsClient, loadConfig } from "../ews_client.ts"; + +export function registerCalendarTools(server: McpServer): void { + server.registerTool( + "get_calendar_events", + { + description: "Get calendar events within a date range", + inputSchema: z.object({ + startDate: z.string().describe("Start date in YYYY-MM-DD format"), + endDate: z.string().describe("End date in YYYY-MM-DD format"), + includeBody: z.boolean().default(false).describe( + "If True, fetch full event details (organizer, attendees, body) via GetItem", + ), + }), + }, + async ({ startDate, endDate, includeBody }) => { + try { + const client = new EwsClient(loadConfig()); + const folderId = await client.getFolderId("calendar"); + const items = await client.findCalendarItems( + folderId, + `${startDate}T00:00:00`, + `${endDate}T23:59:59`, + ); + + const events = []; + for (const item of items) { + const event: any = { + subject: item.Subject ?? "(No subject)", + start: item.Start ?? "", + end: item.End ?? "", + location: item.Location ?? item.EnhancedLocation?.DisplayName ?? "", + isAllDay: item.IsAllDayEvent === "true" || item.IsAllDayEvent === true, + isCancelled: item.IsCancelled === "true" || item.IsCancelled === true, + isMeeting: true, + isRecurring: item.IsRecurring === "true" || item.IsRecurring === true, + organizer: "", + organizerEmail: "", + myResponse: item.MyResponseType ?? "", + itemId: item.ItemId?.["@_Id"] ?? "", + body: "", + requiredAttendees: [], + optionalAttendees: [], + }; + + if (includeBody && event.itemId) { + const details = client.extractEmailDetails(item); + event.body = details.body; + event.requiredAttendees = details.requiredAttendees ?? []; + event.optionalAttendees = details.optionalAttendees ?? []; + event.organizer = details.fromName; + event.organizerEmail = details.from; + event.location = details.location || event.location; + } + + events.push(event); + } + + return { + content: [{ type: "text" as const, text: JSON.stringify({ events, count: events.length }) }], + }; + } catch (error: any) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], + }; + } + }, + ); + + server.registerTool( + "download_event_attachments", + { + description: "Download all file attachments from a calendar event to disk", + inputSchema: z.object({ + itemId: z.string().describe("The Exchange ItemId of the calendar event"), + targetFolder: z.string().default("/tmp/attachments").describe( + "Local directory to save files (default /tmp/attachments)", + ), + }), + }, + async ({ itemId, targetFolder }) => { + try { + const client = new EwsClient(loadConfig()); + const item = await client.getItem(itemId); + const email = client.extractEmailDetails(item); + const attachments = email.attachments ?? []; + + const fileAttachments = attachments.filter( + (a: any) => a.attachmentId && !a.isInline, + ); + + if (!fileAttachments.length) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + downloaded: [], + count: 0, + message: "No downloadable file attachments.", + }), + }], + }; + } + + fs.mkdirSync(targetFolder, { recursive: true }); + + const downloaded = []; + const errors = []; + const usedNames = new Set(); + + for (const att of fileAttachments) { + try { + const result = await client.getAttachment(att.attachmentId); + let filename = path.basename(result.name); + if (!filename) filename = att.name || "attachment"; + + const baseName = filename; + const dotIdx = baseName.lastIndexOf("."); + const namePart = dotIdx > 0 ? baseName.slice(0, dotIdx) : baseName; + const extPart = dotIdx > 0 ? baseName.slice(dotIdx) : ""; + + let counter = 1; + let finalName = filename; + while (usedNames.has(finalName.toLowerCase())) { + finalName = extPart ? `${namePart}_${counter}${extPart}` : `${namePart}_${counter}`; + counter++; + } + usedNames.add(finalName.toLowerCase()); + + const filepath = path.join(targetFolder, finalName); + fs.writeFileSync(filepath, result.content); + + downloaded.push({ + name: finalName, + path: filepath, + size: result.content.length, + contentType: result.contentType, + }); + } catch (e: any) { + errors.push({ name: att.name || "unknown", error: e.message ?? String(e) }); + } + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: errors.length === 0, + downloaded, + count: downloaded.length, + ...(errors.length ? { errors } : {}), + }), + }], + }; + } catch (error: any) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], + }; + } + }, + ); +} diff --git a/tools/email.ts b/tools/email.ts index e9876b2..3730c45 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -1,5 +1,7 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; +import fs from "node:fs"; +import path from "node:path"; import { EwsClient, loadConfig } from "../ews_client.ts"; function getClient(): EwsClient { @@ -285,6 +287,150 @@ export function registerEmailTools(server: McpServer): void { } }, ); + + server.registerTool( + "mark_email_read", + { + description: "Mark one or more emails as read or unread", + inputSchema: z.object({ + itemIds: z.array(z.string()).describe("List of Exchange ItemIds to update"), + isRead: z.boolean().default(true).describe("True to mark as read, False to mark as unread"), + }), + }, + async ({ itemIds, isRead }) => { + try { + const client = new EwsClient(loadConfig()); + const responses = await client.updateItems(itemIds, [ + { fieldUri: "message:IsRead", value: isRead }, + ]); + + const errors = responses + .filter((r: any) => r.ResponseClass === "Error") + .map((r: any) => r.MessageText ?? "Unknown error"); + + if (errors.length) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: errors.join("; ") }) }], + }; + } + + const status = isRead ? "read" : "unread"; + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + message: `Marked ${itemIds.length} email(s) as ${status}.`, + }), + }], + }; + } catch (error: any) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], + }; + } + }, + ); + + server.registerTool( + "download_attachments", + { + description: "Download all file attachments from an email to disk", + inputSchema: z.object({ + itemId: z.string().describe("The Exchange ItemId of the email"), + targetFolder: z.string().default("/tmp/attachments").describe( + "Local directory to save files (default /tmp/attachments)", + ), + }), + }, + async ({ itemId, targetFolder }) => { + try { + const client = new EwsClient(loadConfig()); + const item = await client.getItem(itemId); + const email = client.extractEmailDetails(item); + const attachments = email.attachments ?? []; + + const fileAttachments = attachments.filter( + (a: any) => a.attachmentId && !a.isInline, + ); + + if (!fileAttachments.length) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + downloaded: [], + count: 0, + message: "No downloadable file attachments.", + }), + }], + }; + } + + fs.mkdirSync(targetFolder, { recursive: true }); + + const downloaded = []; + const errors = []; + const usedNames = new Set(); + + for (const att of fileAttachments) { + try { + const result = await client.getAttachment(att.attachmentId); + let filename = path.basename(result.name); + if (!filename) filename = att.name || "attachment"; + + const baseName = filename; + const dotIdx = baseName.lastIndexOf("."); + const namePart = dotIdx > 0 ? baseName.slice(0, dotIdx) : baseName; + const extPart = dotIdx > 0 ? baseName.slice(dotIdx) : ""; + + let counter = 1; + let finalName = filename; + while (usedNames.has(finalName.toLowerCase())) { + finalName = extPart ? `${namePart}_${counter}${extPart}` : `${namePart}_${counter}`; + counter++; + } + usedNames.add(finalName.toLowerCase()); + + const filepath = path.join(targetFolder, finalName); + fs.writeFileSync(filepath, result.content); + + downloaded.push({ + name: finalName, + path: filepath, + size: result.content.length, + contentType: result.contentType, + }); + } catch (e: any) { + errors.push({ name: att.name || "unknown", error: e.message ?? String(e) }); + } + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: errors.length === 0, + downloaded, + count: downloaded.length, + ...(errors.length ? { errors } : {}), + }), + }], + }; + } catch (error: any) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], + }; + } + }, + ); } function formatSearchResults( diff --git a/tools/people.ts b/tools/people.ts new file mode 100644 index 0000000..edbccf0 --- /dev/null +++ b/tools/people.ts @@ -0,0 +1,97 @@ +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod/v4"; +import { EwsClient, loadConfig } from "../ews_client.ts"; + +export function registerPeopleTools(server: McpServer): void { + server.registerTool( + "find_person", + { + description: "Search for people in the corporate directory", + inputSchema: z.object({ + query: z.string().describe( + "Name, email address, or keyword to search for", + ), + }), + }, + async ({ query }) => { + try { + const client = new EwsClient(loadConfig()); + const resolutions = await client.resolveNames(query, true); + + const people = resolutions.map((r: any) => { + const mailbox = r.Mailbox ?? {}; + const contact = r.Contact ?? {}; + + const person: any = { + name: mailbox.Name ?? contact.DisplayName ?? "", + email: mailbox.EmailAddress ?? "", + mailboxType: mailbox.MailboxType ?? "", + firstName: contact.GivenName ?? "", + lastName: contact.Surname ?? "", + jobTitle: contact.JobTitle ?? "", + department: contact.Department ?? "", + company: contact.CompanyName ?? "", + office: contact.OfficeLocation ?? "", + alias: contact.Alias ?? "", + manager: "", + managerEmail: "", + phones: {}, + address: "", + directReports: [], + }; + + const phones = contact.PhoneNumbers?.PhoneNumber ?? []; + const phoneList = Array.isArray(phones) ? phones : [phones]; + for (const p of phoneList) { + if (p?.Key && p?.PhoneNumber) { + person.phones[p.Key] = p.PhoneNumber; + } + } + + const addrs = contact.PhysicalAddresses?.PhysicalAddress ?? []; + const addrList = Array.isArray(addrs) ? addrs : [addrs]; + for (const a of addrList) { + if (a?.Key === "Business") { + const parts = [a.Street, a.City, a.PostalCode, a.CountryOrRegion].filter(Boolean); + if (parts.length) { + person.address = parts.join(", "); + } + } + } + + const managerData = contact.ManagerMailbox?.Mailbox ?? {}; + if (managerData.Name || managerData.EmailAddress) { + person.manager = managerData.Name ?? ""; + person.managerEmail = managerData.EmailAddress ?? ""; + } else if (contact.Manager) { + person.manager = contact.Manager; + } + + const reports = contact.DirectReports?.DirectReport ?? []; + const reportList = Array.isArray(reports) ? reports : [reports]; + for (const rp of reportList) { + if (rp?.Name || rp?.EmailAddress) { + person.directReports.push({ + name: rp.Name ?? "", + email: rp.EmailAddress ?? "", + }); + } + } + + return person; + }); + + return { + content: [{ type: "text" as const, text: JSON.stringify(people) }], + }; + } catch (error: any) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], + }; + } + }, + ); +}