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 } from "../client/ews_client.ts"; import { loadConfig } from "../utils/login.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." + " Supports optional text search via query/queryScope.", inputSchema: z.object({ folder: z.string().default("Inbox").describe( "Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom)." + " Supports Russian folder names", ), limit: z.number().default(10).describe( "Maximum number of emails to return", ), offset: z.number().default(0).describe( "Number of emails to skip for pagination", ), includeBody: z.boolean().default(false).describe( "If true, fetches full email body for each email (slower)", ), unreadOnly: z.boolean().default(false).describe( "If true, only return unread emails", ), query: z.string().optional().describe( "Optional text to search for within the folder", ), queryScope: z.enum(["all", "subject", "body", "from"]).default("all") .describe("Search scope: all, subject, body, or from"), }), outputSchema: z.object({ emails: z.array(z.object({ itemId: z.string(), subject: z.string(), from: z.string(), date: z.string(), isRead: z.boolean(), hasAttachments: z.boolean(), })), count: z.number(), }), }, async ( { folder, limit, offset, includeBody, unreadOnly, query, queryScope, }, ) => { try { const client = getClient(); const maxLimit = 50; if (limit > maxLimit) limit = maxLimit; offset = Math.max(0, offset); const folderId = await client.getFolderId(folder); const baseShape = "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, }); const emails = []; for (const item of items) { emails.push(client.extractEmail(item)); } return { content: [{ type: "text" as const, text: JSON.stringify({ emails, count: emails.length }), }], structuredContent: { 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 by Exchange ItemId.", inputSchema: z.object({ itemId: z.string().describe( "The Exchange ItemId of the email to retrieve", ), }), outputSchema: z.object({ itemId: z.string(), subject: z.string(), from: z.string(), fromName: z.string().optional(), to: z.array(z.string()).optional(), cc: z.array(z.string()).optional(), date: z.string(), body: z.string().optional(), isRead: z.boolean().optional(), hasAttachments: z.boolean().optional(), hasLinks: z.boolean().optional(), attachments: z.array(z.object({ name: z.string(), size: z.number(), contentType: z.string(), attachmentId: z.string().optional(), isInline: z.boolean().optional(), })).optional(), }), }, async ({ itemId }) => { try { const client = getClient(); const item = await client.getItem(itemId); const email = client.extractEmail(item); return { content: [{ type: "text" as const, text: JSON.stringify(email) }], structuredContent: { emails: [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 by their Exchange ItemIds.", 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", ), }), outputSchema: z.object({ success: z.boolean(), message: z.string().optional(), error: z.string().optional(), }), }, 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("; ") }), }], structuredContent: { success: false, 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}.`, }), }], structuredContent: { 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) }), }], structuredContent: { success: false, error: error.message ?? String(error), }, }; } }, ); server.registerTool( "download_attachments", { description: "Download all file attachments from an email to disk." + " Inline (embedded) attachments are skipped —" + " only standalone file attachments are downloaded.", 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", ), }), outputSchema: z.object({ success: z.boolean(), downloaded: z.array(z.object({ name: z.string(), path: z.string(), size: z.number(), contentType: z.string(), })), count: z.number(), errors: z.array(z.object({ name: z.string(), error: z.string(), })).optional(), message: z.string().optional(), }), }, async ({ itemId, targetFolder }) => { try { const client = new EwsClient(loadConfig()); const item = await client.getItem(itemId); const email = client.extractEmail(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.", }), }], structuredContent: { 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 } : {}), }), }], structuredContent: errors.length === 0 ? { success: true, downloaded, count: downloaded.length } : { success: false, downloaded, count: downloaded.length, errors }, }; } catch (error: any) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }), }], }; } }, ); }