From 8d5f254728397cc1ae6da56e0b89a597abfa665e Mon Sep 17 00:00:00 2001 From: albnnc Date: Thu, 9 Jul 2026 00:52:48 +0300 Subject: [PATCH] w --- ews_client.ts | 472 +++++++++++++++++++++++++++++++++++++++ main.ts | 158 ++------------ models.ts | 103 +++++++++ package-lock.json | 546 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 11 +- tools/auth.ts | 93 ++++++++ tools/email.ts | 228 +++++++++++++++++++ tools/folders.ts | 30 +++ 8 files changed, 1494 insertions(+), 147 deletions(-) create mode 100644 ews_client.ts create mode 100644 models.ts create mode 100644 tools/auth.ts create mode 100644 tools/email.ts create mode 100644 tools/folders.ts diff --git a/ews_client.ts b/ews_client.ts new file mode 100644 index 0000000..f0f6620 --- /dev/null +++ b/ews_client.ts @@ -0,0 +1,472 @@ +import { XMLParser } from "fast-xml-parser"; +import { promisify } from "node:util"; +import { createRequire } from "node:module"; +import https from "node:https"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Email, Folder, LoginConfig } from "./models.ts"; + +const ntlm: any = createRequire(import.meta.url)("httpntlm"); +const postAsync = promisify(ntlm.post.bind(ntlm)); + +const SSL_OP_LEGACY_SERVER_CONNECT = 0x00000004; + +const AGENT = new https.Agent({ + keepAlive: true, + rejectUnauthorized: false, + secureOptions: SSL_OP_LEGACY_SERVER_CONNECT, +}); + +const PARSER = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + removeNSPrefix: true, + textNodeName: "#text", +}); + +const DISTINGUISHED_FOLDERS: Record = { + inbox: "inbox", + "входящие": "inbox", + sent: "sentitems", + "отправленные": "sentitems", + drafts: "drafts", + "черновики": "drafts", + deleted: "deleteditems", + "удаленные": "deleteditems", + junk: "junkemail", + "нежелательная почта": "junkemail", + outbox: "outbox", + "исходящие": "outbox", + calendar: "calendar", + "календарь": "calendar", +}; + +function buildSoapEnvelope(body: string): string { + return ` + + +${body} + +`; +} + +function configFilePath(): string { + const dir = path.dirname(fileURLToPath(import.meta.url)); + return path.join(dir, "config.json"); +} + +export function loadConfig(): LoginConfig { + const filePath = configFilePath(); + if (!fs.existsSync(filePath)) { + throw new Error("Not logged in. Use the login tool first."); + } + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as LoginConfig; +} + +export function saveConfig(config: LoginConfig): void { + const filePath = configFilePath(); + fs.writeFileSync(filePath, JSON.stringify(config, null, 2), "utf-8"); +} + +export function clearConfig(): void { + const filePath = configFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} + +export class EwsClient { + private config: LoginConfig; + private ewsUrl: string; + + constructor(config: LoginConfig) { + this.config = config; + this.ewsUrl = `${config.serverUrl.replace(/\/+$/, "")}/EWS/Exchange.asmx`; + } + + private get domain(): string { + return this.config.domain ?? "corp"; + } + + private async soapRequest(body: string, soapAction: string): Promise { + const envelope = buildSoapEnvelope(body); + + const res = await postAsync({ + url: this.ewsUrl, + username: this.config.username, + domain: this.domain, + password: this.config.password, + agent: AGENT, + headers: { + "Content-Type": "text/xml; charset=utf-8", + SOAPAction: soapAction, + }, + body: envelope, + }); + + if (typeof res.body !== "string") { + throw new Error(`NTLM request failed, status=${res.statusCode}`); + } + + return PARSER.parse(res.body); + } + + private extractResponseMessages(data: any): any[] { + const body = data?.Envelope?.Body; + if (!body) return []; + + const firstKey = Object.keys(body).find((k) => k.endsWith("Response")); + if (!firstKey) return []; + + const rm = body[firstKey]?.ResponseMessages; + if (!rm) return []; + + const msgKey = Object.keys(rm).find((k) => k.endsWith("ResponseMessage")); + if (!msgKey) return []; + + const msgs = rm[msgKey]; + return Array.isArray(msgs) ? msgs : [msgs]; + } + + async verifyConnection(): Promise { + try { + await this.getFolderId("inbox"); + return true; + } catch { + return false; + } + } + + async getFolderId(folderName: string): Promise { + const lower = folderName.toLowerCase(); + const distinguished = DISTINGUISHED_FOLDERS[lower]; + + if (distinguished) { + const soap = buildSoapEnvelope(`\ + + + IdOnly + + + + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/GetFolder", + ); + + for (const msg of this.extractResponseMessages(data)) { + const folder = msg?.Folders?.Folder; + if (folder?.FolderId?.["@_Id"]) { + return folder.FolderId["@_Id"]; + } + } + } + + const soap = buildSoapEnvelope(`\ + + + Default + + + + + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/FindFolder", + ); + + for (const msg of this.extractResponseMessages(data)) { + const folders = msg?.RootFolder?.Folders?.Folder; + if (!folders) continue; + const list = Array.isArray(folders) ? folders : [folders]; + for (const f of list) { + if (f.DisplayName?.toLowerCase() === lower) { + return f.FolderId?.["@_Id"] ?? ""; + } + } + } + + throw new Error(`Folder '${folderName}' not found`); + } + + async findItems( + folderId: string, + options: { + limit?: number; + offset?: number; + baseShape?: string; + restriction?: string; + traversal?: string; + } = {}, + ): Promise { + const { + limit = 10, + offset = 0, + baseShape = "AllProperties", + restriction = "", + traversal = "Shallow", + } = options; + + const soap = buildSoapEnvelope(`\ + + + ${baseShape} + + + + + + + + + + +${restriction} + `); + + 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?.Message ?? msg?.RootFolder?.Items?.CalendarItem; + if (!items) continue; + return Array.isArray(items) ? items : [items]; + } + + return []; + } + + async getItem(itemId: string): Promise { + const soap = buildSoapEnvelope(`\ + + + AllProperties + HTML + + + + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/GetItem", + ); + + for (const msg of this.extractResponseMessages(data)) { + const items = msg?.Items; + if (!items) continue; + + const itemKey = Object.keys(items).find((k) => + ["Message", "CalendarItem", "MeetingRequest", "MeetingResponse", "MeetingCancellation"].includes(k) + ); + if (itemKey) { + return items[itemKey]; + } + } + + throw new Error(`Item '${itemId}' not found`); + } + + async findFolders( + parentFolderId: string, + recursive: boolean = false, + ): Promise { + const traversal = recursive ? "Deep" : "Shallow"; + const isDistinguished = DISTINGUISHED_FOLDERS[parentFolderId.toLowerCase()] !== undefined + || ["msgfolderroot"].includes(parentFolderId.toLowerCase()); + + const folderIdXml = isDistinguished + ? `` + : ``; + + const soap = buildSoapEnvelope(`\ + + + Default + + + ${folderIdXml} + + + `); + + const data = await this.soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/FindFolder", + ); + + const folders: Folder[] = []; + + for (const msg of this.extractResponseMessages(data)) { + const folderList = msg?.RootFolder?.Folders?.Folder; + if (!folderList) continue; + const list = Array.isArray(folderList) ? folderList : [folderList]; + + for (const f of list) { + folders.push({ + name: f.DisplayName ?? "Unknown", + id: f.FolderId?.["@_Id"] ?? "", + totalCount: f.TotalCount ?? 0, + unreadCount: f.UnreadCount ?? 0, + childFolderCount: f.ChildFolderCount ?? 0, + }); + } + } + + return folders; + } + + extractEmailSummary(item: any): Email { + const itemType = item["@_xsi_type"] ?? item.__type ?? ""; + const isMeeting = /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test(itemType); + + const fromMailbox = item.From?.Mailbox + ?? item.Organizer?.Mailbox + ?? item.Sender?.Mailbox + ?? {}; + + const email: Email = { + subject: item.Subject ?? "(No subject)", + from: fromMailbox.EmailAddress ?? "", + fromName: fromMailbox.Name ?? "", + date: item.DateTimeSent ?? item.DateTimeReceived ?? item.DateTimeCreated ?? "", + isRead: item.IsRead === "true" || item.IsRead === true, + hasAttachments: item.HasAttachments === "true" || item.HasAttachments === true, + hasLinks: false, + itemId: item.ItemId?.["@_Id"] ?? "", + size: item.Size ? Number(item.Size) : 0, + isMeeting, + itemType: itemType || "Message", + to: [], + cc: [], + body: "", + bodyType: "Text", + attachments: [], + preview: item.Preview ?? "", + }; + + if (item.DisplayTo) { + email.to = item.DisplayTo.split(";").map((t: string) => t.trim()).filter(Boolean); + } + if (item.DisplayCc) { + email.cc = item.DisplayCc.split(";").map((c: string) => c.trim()).filter(Boolean); + } + + if (isMeeting) { + email.location = item.Location ?? ""; + email.start = item.Start ?? item.StartWallClock ?? item.ReminderDueBy ?? ""; + email.end = item.End ?? item.EndWallClock ?? ""; + } + + return email; + } + + extractEmailDetails(item: any): Email { + const email = this.extractEmailSummary(item); + + const bodyVal = item.Body?.Value ?? item.Body?.["#text"] ?? ""; + const bodyType = item.Body?.["@_BodyType"] ?? item.Body?.BodyType ?? "Text"; + + if (bodyType === "HTML") { + email.hasLinks = / { + const name = r.Name ?? ""; + const addr = r.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + + const ccRecipients = item.CcRecipients?.Mailbox ?? []; + email.cc = (Array.isArray(ccRecipients) ? ccRecipients : [ccRecipients]) + .map((r: any) => { + const name = r.Name ?? ""; + const addr = r.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + + const attachments = item.Attachments?.FileAttachment ?? []; + const attList = Array.isArray(attachments) ? attachments : [attachments]; + email.attachments = attList + .filter((a: any) => a) + .map((a: any) => ({ + name: a.Name ?? "", + size: a.Size ? Number(a.Size) : 0, + contentType: a.ContentType ?? "", + attachmentId: a.AttachmentId?.["@_Id"] ?? "", + isInline: a.IsInline === "true" || a.IsInline === true, + })); + + const isMeeting = /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( + item["@_xsi_type"] ?? "", + ); + if (isMeeting) { + email.location = item.Location ?? item.EnhancedLocation?.DisplayName ?? ""; + email.start = item.Start ?? ""; + email.end = item.End ?? ""; + + const reqAtt = item.RequiredAttendees?.Attendee ?? []; + email.requiredAttendees = (Array.isArray(reqAtt) ? reqAtt : [reqAtt]) + .map((a: any) => { + const mb = a.Mailbox ?? {}; + const name = mb.Name ?? ""; + const addr = mb.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + + const optAtt = item.OptionalAttendees?.Attendee ?? []; + email.optionalAttendees = (Array.isArray(optAtt) ? optAtt : [optAtt]) + .map((a: any) => { + const mb = a.Mailbox ?? {}; + const name = mb.Name ?? ""; + const addr = mb.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + } + + return email; + } + + private htmlToText(html: string): string { + if (!html) return ""; + let text = html.replace(/]*>.*?<\/script>/gis, ""); + text = text.replace(/]*>.*?<\/style>/gis, ""); + text = text.replace(//gi, "\n"); + text = text.replace(/]*>/gi, "\n"); + text = text.replace(/<\/p>/gi, ""); + text = text.replace(/]*>/gi, "\n"); + text = text.replace(/<\/div>/gi, ""); + text = text.replace(/<[^>]+>/g, ""); + text = text.replace(/ /g, " "); + text = text.replace(/&/g, "&"); + text = text.replace(/</g, "<"); + text = text.replace(/>/g, ">"); + text = text.replace(/"/g, '"'); + text = text.replace(/'/g, "'"); + text = text.replace(/\n\s*\n/g, "\n\n"); + text = text.replace(/[ \t]+/g, " "); + return text.trim(); + } +} \ No newline at end of file diff --git a/main.ts b/main.ts index e4704d1..f225d16 100644 --- a/main.ts +++ b/main.ts @@ -1,148 +1,20 @@ -import { XMLParser } from "fast-xml-parser"; -import { promisify } from "node:util"; -import { env } from "node:process"; -import { createRequire } from "node:module"; -import https from "node:https"; +import { McpServer } from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { registerAuthTools } from "./tools/auth.ts"; +import { registerEmailTools } from "./tools/email.ts"; +import { registerFolderTools } from "./tools/folders.ts"; -const ntlm: any = createRequire(import.meta.url)("httpntlm"); -const postAsync = promisify(ntlm.post.bind(ntlm)); - -const SSL_OP_LEGACY_SERVER_CONNECT = 0x00000004; - -const AGENT = new https.Agent({ - keepAlive: true, - rejectUnauthorized: false, - secureOptions: SSL_OP_LEGACY_SERVER_CONNECT, -}); - -const EWS_URL = "https://mail.b1.ru/EWS/Exchange.asmx"; - -const PARSER = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: "@_", - removeNSPrefix: true, - textNodeName: "#text", -}); - -function buildSoapEnvelope(body: string): string { - return ` - - -${body} - -`; -} - -async function ewsSoap(options: { body: string; soapAction: string }): Promise { - const res = await postAsync({ - url: EWS_URL, - username: env.EWS_USERNAME ?? "polina.litvinova", - domain: "corp", - password: env.EWS_PASSWORD, - agent: AGENT, - headers: { - "Content-Type": "text/xml; charset=utf-8", - SOAPAction: options.soapAction, - }, - body: options.body, +function buildServer(): McpServer { + const server = new McpServer({ + name: "exchange-mcp", + version: "1.0.0", }); - if (typeof res.body !== "string") { - throw new Error(`NTLM request failed, status=${res.statusCode}`); - } - return PARSER.parse(res.body); + registerAuthTools(server); + registerEmailTools(server); + registerFolderTools(server); + + return server; } -async function getFolder() { - const soap = buildSoapEnvelope(`\ - - - IdOnly - - - - - - - - - - `); - - const data = await ewsSoap({ - body: soap, - soapAction: "http://schemas.microsoft.com/exchange/services/2006/messages/GetFolder", - }); - - const rm = data.Envelope?.Body?.GetFolderResponse?.ResponseMessages; - const msg = rm?.GetFolderResponseMessage; - const folder = msg?.Folders?.Folder; - - console.log(`DisplayName: ${folder?.DisplayName}`); - console.log(`FolderId: ${folder?.FolderId?.["@_Id"]}`); - console.log(`Total items: ${folder?.TotalCount}`); - console.log(`Unread: ${folder?.UnreadCount}`); -} - -async function getLastMessages() { - const soap = buildSoapEnvelope(`\ - - - AllProperties - - - - - - - - - - - `); - - const data = await ewsSoap({ - body: soap, - soapAction: "http://schemas.microsoft.com/exchange/services/2006/messages/FindItem", - }); - - const rm = data.Envelope?.Body?.FindItemResponse?.ResponseMessages; - const msg = rm?.FindItemResponseMessage; - const rootFolder = msg?.RootFolder; - const items = rootFolder?.Items; - const messagesList: any[] = items?.Message ? (Array.isArray(items.Message) ? items.Message : [items.Message]) : []; - - if (!messagesList.length) { - console.log("\n--- No messages found ---"); - return; - } - - console.log(`\n--- ${messagesList.length} message(s) ---`); - for (const m of messagesList) { - const mbox = m.From?.Mailbox; - const author = mbox ? `${mbox.Name ?? "(no name)"} <${mbox.EmailAddress ?? "(no email)"}>` : "(unknown)"; - console.log(`\nSubject: ${m.Subject ?? "(no subject)"}`); - console.log(`From: ${author}`); - console.log(`Date: ${m.DateTimeReceived ?? "(unknown)"}`); - console.log(`Preview: ${(m.Preview ?? "(no preview)").slice(0, 200)}`); - } -} - -async function main() { - if (!env.EWS_PASSWORD) { - console.error("Set EWS_PASSWORD environment variable"); - process.exit(1); - } - - try { - await getFolder(); - await getLastMessages(); - } catch (err) { - console.error("Error:", err); - process.exit(1); - } -} - -main(); \ No newline at end of file +serveStdio(buildServer); \ No newline at end of file diff --git a/models.ts b/models.ts new file mode 100644 index 0000000..4d9575d --- /dev/null +++ b/models.ts @@ -0,0 +1,103 @@ +export interface LoginConfig { + serverUrl: string; + email: string; + username: string; + password: string; + domain?: string; +} + +export interface Email { + subject: string; + from: string; + fromName: string; + date: string; + isRead: boolean; + hasAttachments: boolean; + hasLinks: boolean; + itemId: string; + size: number; + isMeeting: boolean; + itemType: string; + to: string[]; + cc: string[]; + body: string; + bodyType: string; + attachments: Attachment[]; + preview: string; + location?: string; + start?: string; + end?: string; + requiredAttendees?: string[]; + optionalAttendees?: string[]; +} + +export interface Attachment { + name: string; + size: number; + contentType: string; + attachmentId: string; + isInline: boolean; +} + +export interface Folder { + name: string; + id: string; + totalCount: number; + unreadCount: number; + childFolderCount: number; +} + +export interface Person { + name: string; + email: string; + mailboxType: string; + firstName: string; + lastName: string; + jobTitle: string; + department: string; + company: string; + office: string; + alias: string; + manager: string; + managerEmail: string; + phones: Record; + address: string; + directReports: Array<{ name: string; email: string }>; +} + +export interface CalendarEvent { + subject: string; + start: string; + end: string; + location: string; + isAllDay: boolean; + isCancelled: boolean; + isMeeting: boolean; + isRecurring: boolean; + organizer: string; + organizerEmail: string; + myResponse: string; + itemId: string; + body: string; + requiredAttendees: string[]; + optionalAttendees: string[]; +} + +export interface FreeSlot { + date: string; + start: string; + end: string; + durationMinutes: number; +} + +export interface MeetingResult { + success: boolean; + subject: string; + date: string; + startTime: string; + endTime: string; + location: string; + requiredAttendees: string[]; + optionalAttendees: string[]; + error: string; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2bf61c1..742b892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,471 @@ "name": "exchange-mcp", "version": "0.0.0", "dependencies": { + "@modelcontextprotocol/server": "^2.0.0-beta.2", "fast-xml-parser": "^5.2.0", - "httpntlm": "^1.8.13" + "httpntlm": "^1.8.13", + "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^22.0.0", + "@types/node": "^22.20.1", + "tsx": "^4.23.0", "typescript": "^5.9.3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.2.tgz", + "integrity": "sha512-XK+sgFntT5ZzeqtTGpT9uti+Sqmq7YJZdx/N76eLJv9UA9vKvP04Qh9Hc45LCeIHGfTKRwDG6eh4C1Hs4omyIg==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@nodable/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", @@ -60,6 +517,48 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/fast-xml-builder": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", @@ -99,6 +598,21 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/httpntlm": { "version": "1.8.13", "resolved": "https://registry.npmjs.org/httpntlm/-/httpntlm-1.8.13.tgz", @@ -192,6 +706,25 @@ "anynum": "^1.0.1" } }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -233,6 +766,15 @@ "engines": { "node": ">=16.0.0" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 1f68d12..a39c2e4 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,19 @@ "name": "exchange-mcp", "version": "0.0.0", "type": "module", + "scripts": { + "start": "tsx main.ts", + "format": "dprint fmt" + }, "dependencies": { + "@modelcontextprotocol/server": "^2.0.0-beta.2", "fast-xml-parser": "^5.2.0", - "httpntlm": "^1.8.13" + "httpntlm": "^1.8.13", + "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^22.0.0", + "@types/node": "^22.20.1", + "tsx": "^4.23.0", "typescript": "^5.9.3" } } diff --git a/tools/auth.ts b/tools/auth.ts new file mode 100644 index 0000000..ca481ad --- /dev/null +++ b/tools/auth.ts @@ -0,0 +1,93 @@ +import { z } from "zod/v4"; +import { EwsClient, loadConfig, saveConfig, clearConfig } from "../ews_client.ts"; +import { type McpServer } from "@modelcontextprotocol/server"; + +export function registerAuthTools(server: McpServer): void { + server.registerTool( + "login", + { + description: "Authenticate to Exchange EWS using NTLM credentials", + inputSchema: z.object({ + serverUrl: z.string().describe("EWS server URL (e.g. https://mail.example.com)"), + email: z.string().describe("Email address"), + username: z.string().describe("NTLM username"), + password: z.string().describe("NTLM password"), + domain: z.string().optional().describe("NTLM domain (default: corp)"), + }), + }, + async ({ serverUrl, email, username, password, domain }) => { + try { + const config = { + serverUrl: serverUrl.replace(/\/+$/, ""), + email, + username, + password, + domain: domain ?? "corp", + }; + + const client = new EwsClient(config); + const ok = await client.verifyConnection(); + + if (!ok) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: "Connection verification failed. Check credentials and server URL." }) }], + }; + } + + saveConfig(config); + + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: `Logged in as ${email} to ${serverUrl}` }) }], + }; + } catch (error: any) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: error.message ?? String(error) }) }], + }; + } + }, + ); + + server.registerTool( + "check_session", + { + description: "Check whether the current EWS session is authenticated", + inputSchema: z.object({}), + }, + async () => { + try { + const config = loadConfig(); + const client = new EwsClient(config); + const ok = await client.verifyConnection(); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + authenticated: ok, + email: config.email, + serverUrl: config.serverUrl, + }), + }], + }; + } catch (error: any) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ authenticated: false, error: error.message ?? String(error) }) }], + }; + } + }, + ); + + server.registerTool( + "logout", + { + description: "Clear stored credentials", + inputSchema: z.object({}), + }, + async () => { + clearConfig(); + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: "Logged out. Credentials cleared." }) }], + }; + }, + ); +} \ No newline at end of file diff --git a/tools/email.ts b/tools/email.ts new file mode 100644 index 0000000..78a650f --- /dev/null +++ b/tools/email.ts @@ -0,0 +1,228 @@ +import { z } from "zod/v4"; +import { EwsClient, loadConfig } from "../ews_client.ts"; +import { type McpServer } from "@modelcontextprotocol/server"; + +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)"), + }), + }, + async ({ folder, limit, offset, includeBody, unreadOnly, idsOnly }) => { + try { + const client = getClient(); + const maxLimit = idsOnly ? 500 : 50; + if (limit > maxLimit) limit = maxLimit; + + const folderId = await client.getFolderId(folder); + const baseShape = idsOnly ? "IdOnly" : "AllProperties"; + + let restriction = ""; + if (unreadOnly) { + restriction = `\ + + + + + + + + `; + } + + 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( + "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"), + maxResults: z.number().default(20).describe("Maximum number of results (default 20, max 100)"), + searchScope: z.enum(["all", "subject", "body", "from"]).default("all").describe("Where to search"), + }), + }, + async ({ query, folderId, maxResults, searchScope }) => { + try { + const client = getClient(); + + if (!query.trim()) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: "query must not be empty", results: [] }) }], + }; + } + + maxResults = Math.max(1, Math.min(maxResults, 100)); + + 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 traversal = folderId ? "Shallow" : "Deep"; + + if (folderId) { + const items = await client.findItems(folderId, { limit: maxResults, restriction, traversal }); + const results = formatSearchResults(items, client, maxResults); + + 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 }) }], + }; + } + } catch (error: any) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }) }], + }; + } + }, + ); +} + +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; +} \ No newline at end of file diff --git a/tools/folders.ts b/tools/folders.ts new file mode 100644 index 0000000..debb305 --- /dev/null +++ b/tools/folders.ts @@ -0,0 +1,30 @@ +import { z } from "zod/v4"; +import { EwsClient, loadConfig } from "../ews_client.ts"; +import { type McpServer } from "@modelcontextprotocol/server"; + +export function registerFolderTools(server: McpServer): void { + server.registerTool( + "get_folders", + { + description: "List mail folders from the Exchange mailbox", + inputSchema: z.object({ + parentFolderId: z.string().default("msgfolderroot").describe("Parent folder to list children of (default: msgfolderroot)"), + recursive: z.boolean().default(false).describe("If True, traverse all subfolders recursively"), + }), + }, + async ({ parentFolderId, recursive }) => { + try { + const client = new EwsClient(loadConfig()); + const folders = await client.findFolders(parentFolderId, recursive); + + return { + content: [{ type: "text" as const, text: JSON.stringify(folders) }], + }; + } catch (error: any) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }) }], + }; + } + }, + ); +} \ No newline at end of file