From 91f83da567715fc9d87df25bd9caff498137d075 Mon Sep 17 00:00:00 2001 From: albnnc Date: Thu, 9 Jul 2026 15:44:43 +0300 Subject: [PATCH 1/5] w --- ews_client.ts | 181 +++++++++++++++++++++++++++ main.ts | 6 + tools/availability.ts | 281 ++++++++++++++++++++++++++++++++++++++++++ tools/calendar.ts | 173 ++++++++++++++++++++++++++ tools/email.ts | 146 ++++++++++++++++++++++ tools/people.ts | 97 +++++++++++++++ 6 files changed, 884 insertions(+) create mode 100644 tools/availability.ts create mode 100644 tools/calendar.ts create mode 100644 tools/people.ts 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) }), + }], + }; + } + }, + ); +} -- 2.52.0 From 509e49e37491f710465f1138aea98929fa122b54 Mon Sep 17 00:00:00 2001 From: albnnc Date: Thu, 9 Jul 2026 15:46:13 +0300 Subject: [PATCH 2/5] w --- ews_client.ts | 31 +++++++++---- main.ts | 4 +- tools/availability.ts | 100 +++++++++++++++++++++++++++++++----------- tools/calendar.ts | 32 ++++++++++---- tools/email.ts | 24 +++++++--- tools/people.ts | 3 +- 6 files changed, 143 insertions(+), 51 deletions(-) diff --git a/ews_client.ts b/ews_client.ts index 84415ef..8c2f983 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -537,12 +537,16 @@ ${restriction} const changesXml = itemIds.map((id) => { const updatesXml = updates.map((u) => { - const val = typeof u.value === "boolean" ? (u.value ? "true" : "false") : u.value; + const val = typeof u.value === "boolean" + ? (u.value ? "true" : "false") + : u.value; return `\ - ${String(val)} + ${String(val)} `; }).join("\n"); @@ -573,7 +577,9 @@ ${changesXml} // ── GetAttachment ─────────────────────────────────────────────────────── - async getAttachment(attachmentId: string): Promise<{ content: Buffer; name: string; contentType: string }> { + async getAttachment( + attachmentId: string, + ): Promise<{ content: Buffer; name: string; contentType: string }> { const soap = buildSoapEnvelope(`\ @@ -645,13 +651,17 @@ ${changesXml} endDate: string, requestedView: string = "DetailedMerged", ): Promise { - const mailboxDataXml = emails.map((email) => `\ + const mailboxDataXml = emails.map((email) => + `\ - ${email.replace(/&/g, "&").replace(/ + ${ + email.replace(/&/g, "&").replace(/ Required - `).join("\n"); + ` + ).join("\n"); const soap = ` { + async resolveNames( + query: string, + fullContact: boolean = true, + ): Promise { const soap = buildSoapEnvelope(`\ - ${query.replace(/&/g, "&").replace(/ + ${ + query.replace(/&/g, "&").replace(/ `); const data = await this.soapRequest( diff --git a/main.ts b/main.ts index 097250e..3ac4048 100644 --- a/main.ts +++ b/main.ts @@ -6,10 +6,10 @@ import crypto from "node:crypto"; import http from "node:http"; import { initDataDir } from "./ews_client.ts"; import { registerAuthTools } from "./tools/auth.ts"; +import { registerAvailabilityTools } from "./tools/availability.ts"; +import { registerCalendarTools } from "./tools/calendar.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() diff --git a/tools/availability.ts b/tools/availability.ts index 890fdc9..d412177 100644 --- a/tools/availability.ts +++ b/tools/availability.ts @@ -9,10 +9,18 @@ export function registerAvailabilityTools(server: McpServer): void { 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)"), + 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 }) => { @@ -27,9 +35,13 @@ export function registerAvailabilityTools(server: McpServer): void { ed, ); - const body = data?.Envelope?.Body?.GetUserAvailabilityResponse ?? data?.Envelope?.Body ?? {}; - const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse ?? []; - const responses = Array.isArray(freeBusyArray) ? freeBusyArray : [freeBusyArray]; + 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 }[] = []; @@ -63,7 +75,10 @@ export function registerAvailabilityTools(server: McpServer): void { ); return { - content: [{ type: "text" as const, text: JSON.stringify({ freeSlots }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ freeSlots }), + }], }; } catch (error: any) { return { @@ -81,25 +96,43 @@ export function registerAvailabilityTools(server: McpServer): void { { description: "Find meeting times that work for multiple people", inputSchema: z.object({ - emails: z.string().describe("Comma-separated email addresses of attendees"), + 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)"), + 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 }) => { + async ( + { emails, startDate, endDate, durationMinutes, startHour, endHour }, + ) => { try { const client = new EwsClient(loadConfig()); - const emailList = emails.split(",").map((e) => e.trim()).filter(Boolean); + 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 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[] = []; @@ -114,8 +147,14 @@ export function registerAvailabilityTools(server: McpServer): void { 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 }); + 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 ?? []; @@ -125,7 +164,10 @@ export function registerAvailabilityTools(server: McpServer): void { const startStr = ev.StartTime ?? ""; const endStr = ev.EndTime ?? ""; if (startStr && endStr) { - allBusy.push({ start: new Date(startStr), end: new Date(endStr) }); + allBusy.push({ + start: new Date(startStr), + end: new Date(endStr), + }); } } } @@ -184,10 +226,14 @@ function parseFreeBusyString( return periods; } -function mergeBusyPeriods(periods: { start: Date; end: Date }[]): { start: Date; end: Date }[] { +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 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++) { @@ -210,7 +256,10 @@ function findFreeSlots( endHour: number, durationMinutes: number, ): Record { - const result: Record = {}; + const result: Record< + string, + { start: string; end: string; durationMinutes: number }[] + > = {}; const current = new Date(startDate); current.setDate(current.getDate()); @@ -237,7 +286,8 @@ function findFreeSlots( const merged = mergeBusyPeriods(dayBusy); - const slots: { start: string; end: string; durationMinutes: number }[] = []; + const slots: { start: string; end: string; durationMinutes: number }[] = + []; let cursor = new Date(dayStart); for (const bp of merged) { diff --git a/tools/calendar.ts b/tools/calendar.ts index 84883a9..6ed2cd6 100644 --- a/tools/calendar.ts +++ b/tools/calendar.ts @@ -1,7 +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 { z } from "zod/v4"; import { EwsClient, loadConfig } from "../ews_client.ts"; export function registerCalendarTools(server: McpServer): void { @@ -34,10 +34,13 @@ export function registerCalendarTools(server: McpServer): void { 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, + isAllDay: item.IsAllDayEvent === "true" + || item.IsAllDayEvent === true, + isCancelled: item.IsCancelled === "true" + || item.IsCancelled === true, isMeeting: true, - isRecurring: item.IsRecurring === "true" || item.IsRecurring === true, + isRecurring: item.IsRecurring === "true" + || item.IsRecurring === true, organizer: "", organizerEmail: "", myResponse: item.MyResponseType ?? "", @@ -61,7 +64,10 @@ export function registerCalendarTools(server: McpServer): void { } return { - content: [{ type: "text" as const, text: JSON.stringify({ events, count: events.length }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ events, count: events.length }), + }], }; } catch (error: any) { return { @@ -77,9 +83,12 @@ export function registerCalendarTools(server: McpServer): void { server.registerTool( "download_event_attachments", { - description: "Download all file attachments from a calendar event to disk", + 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"), + 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)", ), @@ -130,7 +139,9 @@ export function registerCalendarTools(server: McpServer): void { let counter = 1; let finalName = filename; while (usedNames.has(finalName.toLowerCase())) { - finalName = extPart ? `${namePart}_${counter}${extPart}` : `${namePart}_${counter}`; + finalName = extPart + ? `${namePart}_${counter}${extPart}` + : `${namePart}_${counter}`; counter++; } usedNames.add(finalName.toLowerCase()); @@ -145,7 +156,10 @@ export function registerCalendarTools(server: McpServer): void { contentType: result.contentType, }); } catch (e: any) { - errors.push({ name: att.name || "unknown", error: e.message ?? String(e) }); + errors.push({ + name: att.name || "unknown", + error: e.message ?? String(e), + }); } } diff --git a/tools/email.ts b/tools/email.ts index 3730c45..469aa5d 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -1,7 +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 { z } from "zod/v4"; import { EwsClient, loadConfig } from "../ews_client.ts"; function getClient(): EwsClient { @@ -293,8 +293,12 @@ export function registerEmailTools(server: McpServer): void { { 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"), + 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 }) => { @@ -310,7 +314,10 @@ export function registerEmailTools(server: McpServer): void { if (errors.length) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: errors.join("; ") }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ error: errors.join("; ") }), + }], }; } @@ -391,7 +398,9 @@ export function registerEmailTools(server: McpServer): void { let counter = 1; let finalName = filename; while (usedNames.has(finalName.toLowerCase())) { - finalName = extPart ? `${namePart}_${counter}${extPart}` : `${namePart}_${counter}`; + finalName = extPart + ? `${namePart}_${counter}${extPart}` + : `${namePart}_${counter}`; counter++; } usedNames.add(finalName.toLowerCase()); @@ -406,7 +415,10 @@ export function registerEmailTools(server: McpServer): void { contentType: result.contentType, }); } catch (e: any) { - errors.push({ name: att.name || "unknown", error: e.message ?? String(e) }); + errors.push({ + name: att.name || "unknown", + error: e.message ?? String(e), + }); } } diff --git a/tools/people.ts b/tools/people.ts index edbccf0..b3c411f 100644 --- a/tools/people.ts +++ b/tools/people.ts @@ -52,7 +52,8 @@ export function registerPeopleTools(server: McpServer): void { 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); + const parts = [a.Street, a.City, a.PostalCode, a.CountryOrRegion] + .filter(Boolean); if (parts.length) { person.address = parts.join(", "); } -- 2.52.0 From 91d9befa1e4d3e83f9177e60a91ae018b08441f1 Mon Sep 17 00:00:00 2001 From: albnnc Date: Thu, 9 Jul 2026 16:09:59 +0300 Subject: [PATCH 3/5] w --- SKILL.md | 181 ++++++++++++++++++++++++++++++++++++++++++++++++++++ dprint.json | 12 ++-- 2 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 SKILL.md diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..3ab6aaa --- /dev/null +++ b/SKILL.md @@ -0,0 +1,181 @@ +--- +name: exchange-mcp +description: >- + Use this skill when the user talks about + email, calendar, contacts, Exchange, EWS, corporate mail, + or scheduling meetings. +--- + +# exchange-mcp — MCP server for Microsoft Exchange + +This MCP server provides access to **Microsoft Exchange Server** via **Exchange +Web Services (EWS)** with NTLM authentication. Supports email, calendar, +contacts, and free/busy scheduling. + +**Use this server when the user talks about:** + +- Email, inbox, sent items, drafts +- Calendar, events, meetings, availability +- Contacts, people, colleagues, employees +- Attachments, files in emails +- Exchange, EWS, corporate mail + +**Does not support:** Microsoft 365 / Exchange Online (OAuth), IMAP/POP3/SMTP, +Gmail, Outlook.com. + +--- + +## Tools + +### Auth + +#### `login` + +Authenticate to Exchange EWS via NTLM. + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ------------------------------------------------ | +| `serverUrl` | string | yes | EWS server URL (e.g. `https://mail.example.com`) | +| `email` | string | yes | Email address | +| `username` | string | yes | NTLM username | +| `password` | string | yes | NTLM password | +| `domain` | string | no | NTLM domain (default: `corp`) | + +#### `check_session` + +Check if the current session is authenticated. No parameters. Returns +`{authenticated, email, serverUrl}`. + +#### `logout` + +Clear stored credentials. No parameters. + +--- + +### Email + +#### `get_emails` + +Get emails from a folder. + +| Parameter | Type | Default | Description | +| ------------- | ------- | --------- | ----------------------------------------------------------------------------------- | +| `folder` | string | `"Inbox"` | Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom; supports Russian names) | +| `limit` | number | `10` | Max emails (max 50; max 500 if `idsOnly=true`) | +| `offset` | number | `0` | Pagination offset | +| `includeBody` | boolean | `false` | If `true`, fetches full email body | +| `unreadOnly` | boolean | `false` | Only unread emails | +| `idsOnly` | boolean | `false` | Return only IDs + dates + subjects (faster, higher limit) | + +#### `get_email` + +Get a single email with full body and details. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------- | +| `itemId` | string | yes | Exchange ItemId of the email | + +#### `search_emails` + +Search emails by text. + +| Parameter | Type | Default | Description | +| ------------- | ------ | -------- | --------------------------------------- | +| `query` | string | — | Text to search for | +| `folderId` | string | optional | Folder ID to limit search | +| `maxResults` | number | `20` | Max results (max 100) | +| `searchScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` | + +#### `mark_email_read` + +Mark emails as read or unread. + +| Parameter | Type | Default | Description | +| --------- | -------- | ------- | ------------------------------- | +| `itemIds` | string[] | — | List of Exchange ItemIds | +| `isRead` | boolean | `true` | `true` = read, `false` = unread | + +#### `download_attachments` + +Download all file attachments from an email to disk. + +| Parameter | Type | Default | Description | +| -------------- | ------ | -------------------- | --------------- | +| `itemId` | string | — | Exchange ItemId | +| `targetFolder` | string | `"/tmp/attachments"` | Local folder | + +--- + +### Folders + +#### `get_folders` + +List mailbox folders. + +| Parameter | Type | Default | Description | +| ---------------- | ------- | ----------------- | ----------------------------------------------------------------- | +| `parentFolderId` | string | `"msgfolderroot"` | Parent folder (supports distinguished names: `inbox`, `calendar`) | +| `recursive` | boolean | `false` | Recursively traverse subfolders | + +--- + +### Calendar + +#### `get_calendar_events` + +Get calendar events within a date range. + +| Parameter | Type | Required | Description | +| ------------- | ------- | -------- | ----------------------------------------- | +| `startDate` | string | yes | Start (`YYYY-MM-DD`) | +| `endDate` | string | yes | End (`YYYY-MM-DD`) | +| `includeBody` | boolean | `false` | Full details (organizer, attendees, body) | + +#### `download_event_attachments` + +Download attachments from a calendar event. Same parameters as +`download_attachments`. + +--- + +### Availability + +#### `find_free_time` + +Find free time slots in your calendar. + +| Parameter | Type | Default | Description | +| ----------------- | ------ | ----------- | -------------------------- | +| `startDate` | string | — | Start (`YYYY-MM-DD`) | +| `endDate` | string | = startDate | End | +| `durationMinutes` | number | `30` | Minimum slot duration | +| `startHour` | number | `9` | Work day start hour (0-23) | +| `endHour` | number | `18` | Work day end hour (0-23) | + +#### `find_meeting_time` + +Find common free time for multiple people. + +| Parameter | Type | Default | Description | +| ----------------- | ------ | ----------- | --------------------------------- | +| `emails` | string | — | Attendee emails (comma-separated) | +| `startDate` | string | — | Start | +| `endDate` | string | = startDate | End | +| `durationMinutes` | number | `30` | Minimum slot duration | +| `startHour` | number | `9` | Work day start hour | +| `endHour` | number | `18` | Work day end hour | + +--- + +### People + +#### `find_person` + +Search for people in the corporate directory (Active Directory). + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------------- | +| `query` | string | yes | Name, email, or keyword | + +Returns: name, email, job title, department, company, office, phone, manager, +direct reports. diff --git a/dprint.json b/dprint.json index b9bf997..ba4d613 100644 --- a/dprint.json +++ b/dprint.json @@ -5,14 +5,18 @@ "module.sortImportDeclarations": "caseInsensitive", "module.sortExportDeclarations": "caseInsensitive" }, + "markdown": { + "lineWidth": 80, + "textWrap": "always" + }, "excludes": [ "**/.git", "**/.target" ], "plugins": [ - "https://plugins.dprint.dev/typescript-0.91.1.wasm", - "https://plugins.dprint.dev/json-0.17.4.wasm", - "https://plugins.dprint.dev/markdown-0.15.3.wasm", - "https://plugins.dprint.dev/dockerfile-0.3.0.wasm" + "https://plugins.dprint.dev/typescript-0.96.1.wasm", + "https://plugins.dprint.dev/json-0.23.0.wasm", + "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/dockerfile-0.4.1.wasm" ] } -- 2.52.0 From 8fce57800fbf543abca5a45de3d7d614b08ce033 Mon Sep 17 00:00:00 2001 From: albnnc Date: Thu, 9 Jul 2026 16:20:46 +0300 Subject: [PATCH 4/5] w --- SKILL.md | 13 ++++---- ews_client.ts | 10 +++++- tools/email.ts | 90 +++++++++++++++++--------------------------------- 3 files changed, 46 insertions(+), 67 deletions(-) diff --git a/SKILL.md b/SKILL.md index 3ab6aaa..e6d15f1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -79,12 +79,13 @@ Get a single email with full body and details. Search emails by text. -| Parameter | Type | Default | Description | -| ------------- | ------ | -------- | --------------------------------------- | -| `query` | string | — | Text to search for | -| `folderId` | string | optional | Folder ID to limit search | -| `maxResults` | number | `20` | Max results (max 100) | -| `searchScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` | +| Parameter | Type | Default | Description | +| ------------- | ------ | -------- | ------------------------------------------- | +| `query` | string | — | Text to search for | +| `folderId` | string | optional | Folder ID to limit search; omitted = all | +| `limit` | number | `10` | Max emails (max 50) | +| `offset` | number | `0` | Pagination offset | +| `searchScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` | #### `mark_email_read` diff --git a/ews_client.ts b/ews_client.ts index 8c2f983..41634c9 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -250,6 +250,14 @@ export class EwsClient { traversal = "Shallow", } = options; + const isDistinguished = + DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined + || ["msgfolderroot"].includes(folderId.toLowerCase()); + + const folderIdXml = isDistinguished + ? `` + : ``; + const soap = buildSoapEnvelope(`\ @@ -257,7 +265,7 @@ export class EwsClient { - + ${folderIdXml} diff --git a/tools/email.ts b/tools/email.ts index 469aa5d..6c82f93 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -148,14 +148,17 @@ export function registerEmailTools(server: McpServer): void { folderId: z.string().optional().describe( "Optional folder ID to limit the search. When omitted, searches all mail folders", ), - maxResults: z.number().default(20).describe( - "Maximum number of results (default 20, max 100)", + limit: z.number().default(10).describe( + "Maximum number of emails to return (default 10, max 50)", + ), + offset: z.number().default(0).describe( + "Number of emails to skip for pagination", ), searchScope: z.enum(["all", "subject", "body", "from"]).default("all") .describe("Where to search"), }), }, - async ({ query, folderId, maxResults, searchScope }) => { + async ({ query, folderId, limit, offset, searchScope }) => { try { const client = getClient(); @@ -171,7 +174,8 @@ export function registerEmailTools(server: McpServer): void { }; } - maxResults = Math.max(1, Math.min(maxResults, 100)); + limit = Math.max(1, Math.min(limit, 50)); + offset = Math.max(0, offset); const fieldUriMap: Record = { subject: "item:Subject", @@ -220,63 +224,31 @@ export function registerEmailTools(server: McpServer): void { `; } + const targetFolder = folderId || "msgfolderroot"; const traversal = folderId ? "Shallow" : "Deep"; - if (folderId) { - const items = await client.findItems(folderId, { - limit: maxResults, - restriction, - traversal, - }); - const results = formatSearchResults(items, client, maxResults); + const items = await client.findItems(targetFolder, { + limit, + offset, + restriction, + traversal, + }); + const results = formatSearchResults(items, client); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - query, - searchScope, - folderId, - totalResults: results.length, - results, - }), - }], - }; - } else { - const folders = await client.findFolders("msgfolderroot", true); - const allResults = []; - - for (const f of folders) { - if (allResults.length >= maxResults) break; - const remaining = maxResults - allResults.length; - const items = await client.findItems(f.id, { - limit: remaining, - restriction, - traversal: "Shallow", - }); - const formatted = formatSearchResults(items, client, remaining); - - for (const r of formatted) { - r.folderId = f.id; - r.folderName = f.name; - allResults.push(r); - if (allResults.length >= maxResults) break; - } - } - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - query, - searchScope, - folderId: "all", - totalResults: allResults.length, - results: allResults, - }), - }], - }; - } + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + query, + searchScope, + folderId: folderId || "all", + limit, + offset, + totalResults: results.length, + results, + }), + }], + }; } catch (error: any) { return { content: [{ @@ -448,11 +420,9 @@ export function registerEmailTools(server: McpServer): void { function formatSearchResults( items: any[], client: EwsClient, - maxResults: number, ): any[] { const results: any[] = []; for (const item of items) { - if (results.length >= maxResults) break; const summary = client.extractEmailSummary(item); const bodyHtml = item.Body?.Value ?? item.Body?.["#text"] ?? ""; -- 2.52.0 From 33da3cba1ab8ba633be3be76b290b8c49501472a Mon Sep 17 00:00:00 2001 From: albnnc Date: Thu, 9 Jul 2026 16:40:00 +0300 Subject: [PATCH 5/5] w --- SKILL.md | 17 +--- ews_client.ts | 3 +- tools/email.ts | 221 ++++++++++++++++--------------------------------- 3 files changed, 75 insertions(+), 166 deletions(-) diff --git a/SKILL.md b/SKILL.md index e6d15f1..37e11ed 100644 --- a/SKILL.md +++ b/SKILL.md @@ -56,7 +56,8 @@ Clear stored credentials. No parameters. #### `get_emails` -Get emails from a folder. +Get emails from a folder. Supports optional text search via +`query`/`queryScope`. | Parameter | Type | Default | Description | | ------------- | ------- | --------- | ----------------------------------------------------------------------------------- | @@ -66,6 +67,8 @@ Get emails from a folder. | `includeBody` | boolean | `false` | If `true`, fetches full email body | | `unreadOnly` | boolean | `false` | Only unread emails | | `idsOnly` | boolean | `false` | Return only IDs + dates + subjects (faster, higher limit) | +| `query` | string | — | Text to search for within the folder | +| `queryScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` (only when `query` is set) | #### `get_email` @@ -75,18 +78,6 @@ Get a single email with full body and details. | --------- | ------ | -------- | ---------------------------- | | `itemId` | string | yes | Exchange ItemId of the email | -#### `search_emails` - -Search emails by text. - -| Parameter | Type | Default | Description | -| ------------- | ------ | -------- | ------------------------------------------- | -| `query` | string | — | Text to search for | -| `folderId` | string | optional | Folder ID to limit search; omitted = all | -| `limit` | number | `10` | Max emails (max 50) | -| `offset` | number | `0` | Pagination offset | -| `searchScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` | - #### `mark_email_read` Mark emails as read or unread. diff --git a/ews_client.ts b/ews_client.ts index 41634c9..fe90b3c 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -251,8 +251,7 @@ export class EwsClient { } = options; const isDistinguished = - DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined - || ["msgfolderroot"].includes(folderId.toLowerCase()); + DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined; const folderIdXml = isDistinguished ? `` diff --git a/tools/email.ts b/tools/email.ts index 6c82f93..c5c6825 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -32,27 +32,92 @@ export function registerEmailTools(server: McpServer): void { idsOnly: z.boolean().default(false).describe( "If True, return only item IDs and dates (max limit 500)", ), + query: z.string().optional().describe( + "Optional text to search for within the folder", + ), + queryScope: z.enum(["all", "subject", "body", "from"]).default("all") + .describe("Where to search (only used when query is set)"), }), }, - async ({ folder, limit, offset, includeBody, unreadOnly, idsOnly }) => { + async ( + { + folder, + limit, + offset, + includeBody, + unreadOnly, + idsOnly, + query, + queryScope, + }, + ) => { try { const client = getClient(); const maxLimit = idsOnly ? 500 : 50; if (limit > maxLimit) limit = maxLimit; + offset = Math.max(0, offset); const folderId = await client.getFolderId(folder); const baseShape = idsOnly ? "IdOnly" : "AllProperties"; - let restriction = ""; + if (query && !query.trim()) query = undefined; + + const restrictions: string[] = []; + if (unreadOnly) { - restriction = `\ - + restrictions.push(`\ - + `); + } + + if (query) { + function containsExpression(fieldUri: string, value: string): string { + return `\ + + + /g, + ">", + ).replace(/"/g, """) + }"/> + `; + } + + const fieldUriMap: Record = { + subject: "item:Subject", + body: "item:Body", + from: "message:From", + }; + + if (queryScope === "all") { + restrictions.push(`\ + + ${containsExpression("item:Subject", query)} + ${containsExpression("item:Body", query)} + `); + } else { + const fieldUri = fieldUriMap[queryScope]; + restrictions.push(containsExpression(fieldUri, query)); + } + } + + let restriction = ""; + if (restrictions.length === 1) { + restriction = `\ + + ${restrictions[0]} + `; + } else if (restrictions.length > 1) { + restriction = `\ + + + ${restrictions.join("\n")} + `; } @@ -139,127 +204,6 @@ export function registerEmailTools(server: McpServer): void { }, ); - server.registerTool( - "search_emails", - { - description: "Search emails by text across one or all folders", - inputSchema: z.object({ - query: z.string().describe("The text to search for"), - folderId: z.string().optional().describe( - "Optional folder ID to limit the search. When omitted, searches all mail folders", - ), - limit: z.number().default(10).describe( - "Maximum number of emails to return (default 10, max 50)", - ), - offset: z.number().default(0).describe( - "Number of emails to skip for pagination", - ), - searchScope: z.enum(["all", "subject", "body", "from"]).default("all") - .describe("Where to search"), - }), - }, - async ({ query, folderId, limit, offset, searchScope }) => { - try { - const client = getClient(); - - if (!query.trim()) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: "query must not be empty", - results: [], - }), - }], - }; - } - - limit = Math.max(1, Math.min(limit, 50)); - offset = Math.max(0, offset); - - const fieldUriMap: Record = { - subject: "item:Subject", - body: "item:Body", - from: "message:From", - }; - - function containsExpression(fieldUri: string, value: string): string { - return `\ - - - /g, - ">", - ).replace(/"/g, """) - }"/> - `; - } - - let restriction: string; - if (searchScope === "all") { - restriction = `\ - - - ${containsExpression("item:Subject", query)} - ${containsExpression("item:Body", query)} - - `; - } else { - const fieldUri = fieldUriMap[searchScope]; - if (!fieldUri) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: `unsupported search_scope: ${searchScope}`, - results: [], - }), - }], - }; - } - restriction = `\ - - ${containsExpression(fieldUri, query)} - `; - } - - const targetFolder = folderId || "msgfolderroot"; - const traversal = folderId ? "Shallow" : "Deep"; - - const items = await client.findItems(targetFolder, { - limit, - offset, - restriction, - traversal, - }); - const results = formatSearchResults(items, client); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - query, - searchScope, - folderId: folderId || "all", - limit, - offset, - totalResults: results.length, - results, - }), - }], - }; - } catch (error: any) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: error.message ?? String(error) }), - }], - }; - } - }, - ); - server.registerTool( "mark_email_read", { @@ -416,28 +360,3 @@ export function registerEmailTools(server: McpServer): void { }, ); } - -function formatSearchResults( - items: any[], - client: EwsClient, -): any[] { - const results: any[] = []; - for (const item of items) { - const summary = client.extractEmailSummary(item); - - const bodyHtml = item.Body?.Value ?? item.Body?.["#text"] ?? ""; - const bodyType = item.Body?.["@_BodyType"] ?? "HTML"; - let bodyPreview = ""; - if (bodyType === "HTML" && bodyHtml) { - bodyPreview = bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ") - .trim().slice(0, 200); - } else { - bodyPreview = bodyHtml.slice(0, 200); - } - - summary.preview = bodyPreview; - - results.push(summary); - } - return results; -} -- 2.52.0