diff --git a/ews_client.ts b/ews_client.ts index 7c09d35..731d490 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -1,10 +1,10 @@ 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 https from "node:https"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import type { Email, Folder, LoginConfig } from "./models.ts"; const ntlm: any = createRequire(import.meta.url)("httpntlm"); @@ -91,7 +91,7 @@ export class EwsClient { return this.config.domain ?? "corp"; } -private async soapRequest(body: string, soapAction: string): Promise { + private async soapRequest(body: string, soapAction: string): Promise { const res = await postAsync({ url: this.ewsUrl, username: this.config.username, @@ -154,7 +154,7 @@ private async soapRequest(body: string, soapAction: string): Promise { `); -const data = await this.soapRequest( + const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/GetFolder", ); @@ -238,7 +238,8 @@ ${restriction} ); for (const msg of this.extractResponseMessages(data)) { - const items = msg?.RootFolder?.Items?.Message ?? msg?.RootFolder?.Items?.CalendarItem; + const items = msg?.RootFolder?.Items?.Message + ?? msg?.RootFolder?.Items?.CalendarItem; if (!items) continue; return Array.isArray(items) ? items : [items]; } @@ -268,7 +269,13 @@ ${restriction} if (!items) continue; const itemKey = Object.keys(items).find((k) => - ["Message", "CalendarItem", "MeetingRequest", "MeetingResponse", "MeetingCancellation"].includes(k) + [ + "Message", + "CalendarItem", + "MeetingRequest", + "MeetingResponse", + "MeetingCancellation", + ].includes(k) ); if (itemKey) { return items[itemKey]; @@ -283,7 +290,8 @@ ${restriction} recursive: boolean = false, ): Promise { const traversal = recursive ? "Deep" : "Shallow"; - const isDistinguished = DISTINGUISHED_FOLDERS[parentFolderId.toLowerCase()] !== undefined + const isDistinguished = + DISTINGUISHED_FOLDERS[parentFolderId.toLowerCase()] !== undefined || ["msgfolderroot"].includes(parentFolderId.toLowerCase()); const folderIdXml = isDistinguished @@ -329,7 +337,10 @@ ${restriction} extractEmailSummary(item: any): Email { const itemType = item["@_xsi_type"] ?? item.__type ?? ""; - const isMeeting = /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test(itemType); + const isMeeting = + /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( + itemType, + ); const fromMailbox = item.From?.Mailbox ?? item.Organizer?.Mailbox @@ -340,9 +351,11 @@ ${restriction} subject: item.Subject ?? "(No subject)", from: fromMailbox.EmailAddress ?? "", fromName: fromMailbox.Name ?? "", - date: item.DateTimeSent ?? item.DateTimeReceived ?? item.DateTimeCreated ?? "", + date: item.DateTimeSent ?? item.DateTimeReceived ?? item.DateTimeCreated + ?? "", isRead: item.IsRead === "true" || item.IsRead === true, - hasAttachments: item.HasAttachments === "true" || item.HasAttachments === true, + hasAttachments: item.HasAttachments === "true" + || item.HasAttachments === true, hasLinks: false, itemId: item.ItemId?.["@_Id"] ?? "", size: item.Size ? Number(item.Size) : 0, @@ -357,15 +370,20 @@ ${restriction} }; if (item.DisplayTo) { - email.to = item.DisplayTo.split(";").map((t: string) => t.trim()).filter(Boolean); + 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); + 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.start = item.Start ?? item.StartWallClock ?? item.ReminderDueBy + ?? ""; email.end = item.End ?? item.EndWallClock ?? ""; } @@ -416,11 +434,13 @@ ${restriction} isInline: a.IsInline === "true" || a.IsInline === true, })); - const isMeeting = /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( - item["@_xsi_type"] ?? "", - ); + const isMeeting = + /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( + item["@_xsi_type"] ?? "", + ); if (isMeeting) { - email.location = item.Location ?? item.EnhancedLocation?.DisplayName ?? ""; + email.location = item.Location ?? item.EnhancedLocation?.DisplayName + ?? ""; email.start = item.Start ?? ""; email.end = item.End ?? ""; @@ -462,10 +482,10 @@ ${restriction} 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 9a7138b..bf53607 100644 --- a/main.ts +++ b/main.ts @@ -1,9 +1,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Command } from "commander"; -import http from "node:http"; import crypto from "node:crypto"; +import http from "node:http"; import { registerAuthTools } from "./tools/auth.ts"; import { registerEmailTools } from "./tools/email.ts"; import { registerFolderTools } from "./tools/folders.ts"; @@ -12,7 +12,10 @@ const program = new Command() .name("exchange-mcp") .description("Exchange MCP Server — EWS integration via MCP") .option("--transport ", "Transport: stdio or sse", "stdio") - .option("--token-hash ", "SHA-256 hash of bearer token for HTTP transport auth") + .option( + "--token-hash ", + "SHA-256 hash of bearer token for HTTP transport auth", + ) .option("--host ", "HTTP host", "127.0.0.1") .option("--port ", "HTTP port", "8000"); @@ -55,7 +58,10 @@ async function runSSE() { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization", + ); if (req.method === "OPTIONS") { res.writeHead(200); @@ -113,7 +119,9 @@ async function main() { await runSSE(); break; default: - console.error(`Unsupported transport: "${transportType}". Use "stdio" or "sse".`); + console.error( + `Unsupported transport: "${transportType}". Use "stdio" or "sse".`, + ); process.exit(1); } } @@ -121,4 +129,4 @@ async function main() { main().catch((error) => { console.error("Fatal error:", error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/models.ts b/models.ts index 4d9575d..e374a83 100644 --- a/models.ts +++ b/models.ts @@ -100,4 +100,4 @@ export interface MeetingResult { requiredAttendees: string[]; optionalAttendees: string[]; error: string; -} \ No newline at end of file +} diff --git a/tools/auth.ts b/tools/auth.ts index d467e4d..b5a6427 100644 --- a/tools/auth.ts +++ b/tools/auth.ts @@ -1,6 +1,11 @@ -import { z } from "zod/v4"; -import { EwsClient, loadConfig, saveConfig, clearConfig } from "../ews_client.ts"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod/v4"; +import { + clearConfig, + EwsClient, + loadConfig, + saveConfig, +} from "../ews_client.ts"; export function registerAuthTools(server: McpServer): void { server.registerTool( @@ -8,7 +13,9 @@ export function registerAuthTools(server: McpServer): void { { description: "Authenticate to Exchange EWS using NTLM credentials", inputSchema: z.object({ - serverUrl: z.string().describe("EWS server URL (e.g. https://mail.example.com)"), + 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"), @@ -30,18 +37,36 @@ export function registerAuthTools(server: McpServer): void { if (!result.ok) { return { - content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: result.error ?? "Connection verification failed" }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: false, + error: result.error ?? "Connection verification failed", + }), + }], }; } saveConfig(config); return { - content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: `Logged in as ${email} to ${serverUrl}` }) }], + 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) }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: false, + error: error.message ?? String(error), + }), + }], }; } }, @@ -72,7 +97,13 @@ export function registerAuthTools(server: McpServer): void { }; } catch (error: any) { return { - content: [{ type: "text" as const, text: JSON.stringify({ authenticated: false, error: error.message ?? String(error) }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ + authenticated: false, + error: error.message ?? String(error), + }), + }], }; } }, @@ -87,8 +118,14 @@ export function registerAuthTools(server: McpServer): void { async () => { clearConfig(); return { - content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: "Logged out. Credentials cleared." }) }], + 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 index cead16d..e9876b2 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -1,6 +1,6 @@ +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; import { EwsClient, loadConfig } from "../ews_client.ts"; -import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; function getClient(): EwsClient { return new EwsClient(loadConfig()); @@ -12,12 +12,24 @@ export function registerEmailTools(server: McpServer): void { { 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)"), + 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 }) => { @@ -42,7 +54,12 @@ export function registerEmailTools(server: McpServer): void { `; } - const items = await client.findItems(folderId, { limit, offset, baseShape, restriction }); + const items = await client.findItems(folderId, { + limit, + offset, + baseShape, + restriction, + }); if (idsOnly) { const result = items.map((item: any) => ({ @@ -51,7 +68,10 @@ export function registerEmailTools(server: McpServer): void { subject: item.Subject ?? "", })); return { - content: [{ type: "text" as const, text: JSON.stringify({ itemIds: result, count: result.length }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ itemIds: result, count: result.length }), + }], }; } @@ -71,11 +91,17 @@ export function registerEmailTools(server: McpServer): void { } return { - content: [{ type: "text" as const, text: JSON.stringify({ emails, count: emails.length }) }], + 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) }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], }; } }, @@ -86,7 +112,9 @@ export function registerEmailTools(server: McpServer): void { { 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"), + itemId: z.string().describe( + "The Exchange ItemId of the email to retrieve", + ), }), }, async ({ itemId }) => { @@ -100,7 +128,10 @@ export function registerEmailTools(server: McpServer): void { }; } catch (error: any) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], }; } }, @@ -112,9 +143,14 @@ export function registerEmailTools(server: McpServer): void { 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"), + 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 }) => { @@ -123,7 +159,13 @@ export function registerEmailTools(server: McpServer): void { if (!query.trim()) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: "query must not be empty", results: [] }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ + error: "query must not be empty", + results: [], + }), + }], }; } @@ -139,7 +181,12 @@ export function registerEmailTools(server: McpServer): void { return `\ - /g, ">").replace(/"/g, """)}"/> + /g, + ">", + ).replace(/"/g, """) + }"/> `; } @@ -156,7 +203,13 @@ export function registerEmailTools(server: McpServer): void { const fieldUri = fieldUriMap[searchScope]; if (!fieldUri) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: `unsupported search_scope: ${searchScope}`, results: [] }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ + error: `unsupported search_scope: ${searchScope}`, + results: [], + }), + }], }; } restriction = `\ @@ -168,11 +221,24 @@ export function registerEmailTools(server: McpServer): void { const traversal = folderId ? "Shallow" : "Deep"; if (folderId) { - const items = await client.findItems(folderId, { limit: maxResults, restriction, traversal }); + 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 }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ + query, + searchScope, + folderId, + totalResults: results.length, + results, + }), + }], }; } else { const folders = await client.findFolders("msgfolderroot", true); @@ -181,7 +247,11 @@ export function registerEmailTools(server: McpServer): void { 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 items = await client.findItems(f.id, { + limit: remaining, + restriction, + traversal: "Shallow", + }); const formatted = formatSearchResults(items, client, remaining); for (const r of formatted) { @@ -193,19 +263,35 @@ export function registerEmailTools(server: McpServer): void { } return { - content: [{ type: "text" as const, text: JSON.stringify({ query, searchScope, folderId: "all", totalResults: allResults.length, results: allResults }) }], + 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) }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], }; } }, ); } -function formatSearchResults(items: any[], client: EwsClient, maxResults: number): any[] { +function formatSearchResults( + items: any[], + client: EwsClient, + maxResults: number, +): any[] { const results: any[] = []; for (const item of items) { if (results.length >= maxResults) break; @@ -215,7 +301,8 @@ function formatSearchResults(items: any[], client: EwsClient, maxResults: number const bodyType = item.Body?.["@_BodyType"] ?? "HTML"; let bodyPreview = ""; if (bodyType === "HTML" && bodyHtml) { - bodyPreview = bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 200); + bodyPreview = bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ") + .trim().slice(0, 200); } else { bodyPreview = bodyHtml.slice(0, 200); } @@ -225,4 +312,4 @@ function formatSearchResults(items: any[], client: EwsClient, maxResults: number results.push(summary); } return results; -} \ No newline at end of file +} diff --git a/tools/folders.ts b/tools/folders.ts index 01c2763..1615a65 100644 --- a/tools/folders.ts +++ b/tools/folders.ts @@ -1,6 +1,6 @@ +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; import { EwsClient, loadConfig } from "../ews_client.ts"; -import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; export function registerFolderTools(server: McpServer): void { server.registerTool( @@ -8,8 +8,12 @@ export function registerFolderTools(server: McpServer): void { { 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"), + 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 }) => { @@ -22,9 +26,12 @@ export function registerFolderTools(server: McpServer): void { }; } catch (error: any) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }) }], + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error.message ?? String(error) }), + }], }; } }, ); -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 424bf37..1bc424c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,4 +39,4 @@ "useUnknownInCatchVariables": true, "verbatimModuleSyntax": true } -} \ No newline at end of file +}