diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..37e11ed --- /dev/null +++ b/SKILL.md @@ -0,0 +1,173 @@ +--- +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. Supports optional text search via +`query`/`queryScope`. + +| 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) | +| `query` | string | — | Text to search for within the folder | +| `queryScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` (only when `query` is set) | + +#### `get_email` + +Get a single email with full body and details. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------- | +| `itemId` | string | yes | Exchange ItemId of the email | + +#### `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" ] } diff --git a/ews_client.ts b/ews_client.ts index dca9434..fe90b3c 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -250,6 +250,13 @@ export class EwsClient { traversal = "Shallow", } = options; + const isDistinguished = + DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined; + + const folderIdXml = isDistinguished + ? `` + : ``; + const soap = buildSoapEnvelope(`\ @@ -257,7 +264,7 @@ export class EwsClient { - + ${folderIdXml} @@ -523,4 +530,200 @@ ${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..3ac4048 100644 --- a/main.ts +++ b/main.ts @@ -6,8 +6,11 @@ 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 { 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..d412177 --- /dev/null +++ b/tools/availability.ts @@ -0,0 +1,331 @@ +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); +} diff --git a/tools/calendar.ts b/tools/calendar.ts new file mode 100644 index 0000000..6ed2cd6 --- /dev/null +++ b/tools/calendar.ts @@ -0,0 +1,187 @@ +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +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 { + 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..c5c6825 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -1,4 +1,6 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import fs from "node:fs"; +import path from "node:path"; import { z } from "zod/v4"; import { EwsClient, loadConfig } from "../ews_client.ts"; @@ -30,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")} + `; } @@ -138,143 +205,150 @@ export function registerEmailTools(server: McpServer): void { ); server.registerTool( - "search_emails", + "mark_email_read", { - description: "Search emails by text across one or all folders", + description: "Mark one or more emails as read or unread", 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", + itemIds: z.array(z.string()).describe( + "List of Exchange ItemIds to update", ), - maxResults: z.number().default(20).describe( - "Maximum number of results (default 20, max 100)", + isRead: z.boolean().default(true).describe( + "True to mark as read, False to mark as unread", ), - searchScope: z.enum(["all", "subject", "body", "from"]).default("all") - .describe("Where to search"), }), }, - async ({ query, folderId, maxResults, searchScope }) => { + async ({ itemIds, isRead }) => { try { - const client = getClient(); + const client = new EwsClient(loadConfig()); + const responses = await client.updateItems(itemIds, [ + { fieldUri: "message:IsRead", value: isRead }, + ]); - if (!query.trim()) { + 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: "query must not be empty", - results: [], - }), + text: JSON.stringify({ error: errors.join("; ") }), }], }; } - maxResults = Math.max(1, Math.min(maxResults, 100)); - - const fieldUriMap: Record = { - subject: "item:Subject", - body: "item:Body", - from: "message:From", + 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) }), + }], + }; + } + }, + ); - function containsExpression(fieldUri: string, value: string): string { - return `\ - - - /g, - ">", - ).replace(/"/g, """) - }"/> - `; - } + 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 ?? []; - 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 traversal = folderId ? "Shallow" : "Deep"; - - if (folderId) { - const items = await client.findItems(folderId, { - limit: maxResults, - restriction, - traversal, - }); - const results = formatSearchResults(items, client, maxResults); + const fileAttachments = attachments.filter( + (a: any) => a.attachmentId && !a.isInline, + ); + if (!fileAttachments.length) { return { content: [{ type: "text" as const, text: JSON.stringify({ - query, - searchScope, - folderId, - totalResults: results.length, - results, + success: true, + downloaded: [], + count: 0, + message: "No downloadable file attachments.", }), }], }; - } 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); + fs.mkdirSync(targetFolder, { recursive: true }); - for (const r of formatted) { - r.folderId = f.id; - r.folderName = f.name; - allResults.push(r); - if (allResults.length >= maxResults) break; + 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()); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - query, - searchScope, - folderId: "all", - totalResults: allResults.length, - results: allResults, - }), - }], - }; + 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: [{ @@ -286,30 +360,3 @@ 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"] ?? ""; - 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; -} diff --git a/tools/people.ts b/tools/people.ts new file mode 100644 index 0000000..b3c411f --- /dev/null +++ b/tools/people.ts @@ -0,0 +1,98 @@ +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) }), + }], + }; + } + }, + ); +}