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"; function getClient(): EwsClient { return new EwsClient(loadConfig()); } export function registerEmailTools(server: McpServer): void { server.registerTool( "get_emails", { description: "Get emails from a mailbox folder", inputSchema: z.object({ folder: z.string().default("Inbox").describe( "Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom)", ), 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", ), includeBody: z.boolean().default(false).describe( "If True, fetch full body for each email (slower)", ), unreadOnly: z.boolean().default(false).describe( "If True, only return unread emails", ), 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, 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"; if (query && !query.trim()) query = undefined; const restrictions: string[] = []; if (unreadOnly) { 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")} `; } const items = await client.findItems(folderId, { limit, offset, baseShape, restriction, }); if (idsOnly) { const result = items.map((item: any) => ({ itemId: item.ItemId?.["@_Id"] ?? "", date: item.DateTimeReceived ?? "", subject: item.Subject ?? "", })); return { content: [{ type: "text" as const, text: JSON.stringify({ itemIds: result, count: result.length }), }], }; } const emails = []; for (const item of items) { const email = client.extractEmailSummary(item); if (includeBody && email.itemId) { const details = client.extractEmailDetails(item); email.to = details.to; email.cc = details.cc; email.body = details.body; email.hasLinks = details.hasLinks; } emails.push(email); } return { content: [{ type: "text" as const, text: JSON.stringify({ emails, count: emails.length }), }], }; } catch (error: any) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }), }], }; } }, ); server.registerTool( "get_email", { description: "Get a single email with full body and details", inputSchema: z.object({ itemId: z.string().describe( "The Exchange ItemId of the email to retrieve", ), }), }, async ({ itemId }) => { try { const client = getClient(); const item = await client.getItem(itemId); const email = client.extractEmailDetails(item); return { content: [{ type: "text" as const, text: JSON.stringify(email) }], }; } catch (error: any) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }), }], }; } }, ); 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) }), }], }; } }, ); }