5 Commits

Author SHA1 Message Date
albnnc 653eaecf2e w 2026-07-09 02:19:40 +03:00
albnnc 9697d8724d w 2026-07-09 01:20:09 +03:00
albnnc 4d621e0a54 w 2026-07-09 01:12:40 +03:00
albnnc cefb27349c w 2026-07-09 00:57:54 +03:00
albnnc 8d5f254728 w 2026-07-09 00:52:48 +03:00
9 changed files with 2727 additions and 133 deletions
+1
View File
@@ -10,3 +10,4 @@
.vscode
.yarn
node_modules
config.json
+471
View File
@@ -0,0 +1,471 @@
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<string, string> = {
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 `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
<s:Body>
${body}
</s:Body>
</s:Envelope>`;
}
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<any> {
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,
});
if (typeof res.body !== "string") {
throw new Error(`NTLM request failed, status=${res.statusCode}`);
}
const parsed = PARSER.parse(res.body);
return parsed;
}
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<{ ok: boolean; error?: string }> {
try {
await this.getFolderId("inbox");
return { ok: true };
} catch (error: any) {
return { ok: false, error: error.message ?? String(error) };
}
}
async getFolderId(folderName: string): Promise<string> {
const lower = folderName.toLowerCase();
const distinguished = DISTINGUISHED_FOLDERS[lower];
if (distinguished) {
const soap = buildSoapEnvelope(`\
<m:GetFolder>
<m:FolderShape>
<t:BaseShape>IdOnly</t:BaseShape>
</m:FolderShape>
<m:FolderIds>
<t:DistinguishedFolderId Id="${distinguished}"/>
</m:FolderIds>
</m:GetFolder>`);
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(`\
<m:FindFolder Traversal="Shallow">
<m:FolderShape>
<t:BaseShape>Default</t:BaseShape>
</m:FolderShape>
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="msgfolderroot"/>
</m:ParentFolderIds>
<m:IndexedPageFolderView MaxEntriesReturned="200" Offset="0" BasePoint="Beginning"/>
</m:FindFolder>`);
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<any[]> {
const {
limit = 10,
offset = 0,
baseShape = "AllProperties",
restriction = "",
traversal = "Shallow",
} = options;
const soap = buildSoapEnvelope(`\
<m:FindItem Traversal="${traversal}">
<m:ItemShape>
<t:BaseShape>${baseShape}</t:BaseShape>
</m:ItemShape>
<m:IndexedPageItemView MaxEntriesReturned="${limit}" Offset="${offset}" BasePoint="Beginning"/>
<m:ParentFolderIds>
<t:FolderId Id="${folderId}"/>
</m:ParentFolderIds>
<m:SortOrder>
<t:FieldOrder Order="Descending">
<t:FieldURI FieldURI="item:DateTimeReceived"/>
</t:FieldOrder>
</m:SortOrder>
${restriction}
</m:FindItem>`);
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<any> {
const soap = buildSoapEnvelope(`\
<m:GetItem>
<m:ItemShape>
<t:BaseShape>AllProperties</t:BaseShape>
<t:BodyType>HTML</t:BodyType>
</m:ItemShape>
<m:ItemIds>
<t:ItemId Id="${itemId}"/>
</m:ItemIds>
</m:GetItem>`);
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<Folder[]> {
const traversal = recursive ? "Deep" : "Shallow";
const isDistinguished = DISTINGUISHED_FOLDERS[parentFolderId.toLowerCase()] !== undefined
|| ["msgfolderroot"].includes(parentFolderId.toLowerCase());
const folderIdXml = isDistinguished
? `<t:DistinguishedFolderId Id="${parentFolderId}"/>`
: `<t:FolderId Id="${parentFolderId}"/>`;
const soap = buildSoapEnvelope(`\
<m:FindFolder Traversal="${traversal}">
<m:FolderShape>
<t:BaseShape>Default</t:BaseShape>
</m:FolderShape>
<m:ParentFolderIds>
${folderIdXml}
</m:ParentFolderIds>
<m:IndexedPageFolderView MaxEntriesReturned="200" Offset="0" BasePoint="Beginning"/>
</m:FindFolder>`);
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 = /<a\s/i.test(bodyVal);
email.body = this.htmlToText(bodyVal);
} else {
email.body = bodyVal;
}
email.bodyType = bodyType;
const toRecipients = item.ToRecipients?.Mailbox ?? [];
email.to = (Array.isArray(toRecipients) ? toRecipients : [toRecipients])
.map((r: any) => {
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[^>]*>.*?<\/script>/gis, "");
text = text.replace(/<style[^>]*>.*?<\/style>/gis, "");
text = text.replace(/<br\s*\/?>/gi, "\n");
text = text.replace(/<p[^>]*>/gi, "\n");
text = text.replace(/<\/p>/gi, "");
text = text.replace(/<div[^>]*>/gi, "\n");
text = text.replace(/<\/div>/gi, "");
text = text.replace(/<[^>]+>/g, "");
text = text.replace(/&nbsp;/g, " ");
text = text.replace(/&amp;/g, "&");
text = text.replace(/&lt;/g, "<");
text = text.replace(/&gt;/g, ">");
text = text.replace(/&quot;/g, '"');
text = text.replace(/&#39;/g, "'");
text = text.replace(/\n\s*\n/g, "\n\n");
text = text.replace(/[ \t]+/g, " ");
return text.trim();
}
}
+104 -128
View File
@@ -1,148 +1,124 @@
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/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { Command } from "commander";
import http from "node:http";
import crypto from "node:crypto";
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 program = new Command()
.name("exchange-mcp")
.description("Exchange MCP Server — EWS integration via MCP")
.option("--transport <type>", "Transport: stdio or sse", "stdio")
.option("--token-hash <hash>", "SHA-256 hash of bearer token for HTTP transport auth")
.option("--host <host>", "HTTP host", "127.0.0.1")
.option("--port <port>", "HTTP port", "8000");
const SSL_OP_LEGACY_SERVER_CONNECT = 0x00000004;
program.parse(process.argv);
const options = program.opts();
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 `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
<s:Body>
${body}
</s:Body>
</s:Envelope>`;
}
async function ewsSoap(options: { body: string; soapAction: string }): Promise<any> {
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}`);
registerAuthTools(server);
registerEmailTools(server);
registerFolderTools(server);
return server;
}
async function runStdio() {
const server = buildServer();
const transport = new StdioServerTransport();
await server.connect(transport);
}
async function runSSE() {
const transports = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
if (options.tokenHash) {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
const hash = crypto.createHash("sha256").update(token).digest("hex");
if (hash !== options.tokenHash) {
res.writeHead(401, { "Content-Type": "text/plain" });
res.end("Unauthorized");
return;
}
}
return PARSER.parse(res.body);
}
async function getFolder() {
const soap = buildSoapEnvelope(`\
<m:GetFolder>
<m:FolderShape>
<t:BaseShape>IdOnly</t:BaseShape>
<t:AdditionalProperties>
<t:FieldURI FieldURI="folder:DisplayName"/>
<t:FieldURI FieldURI="folder:TotalCount"/>
<t:FieldURI FieldURI="folder:UnreadCount"/>
</t:AdditionalProperties>
</m:FolderShape>
<m:FolderIds>
<t:DistinguishedFolderId Id="inbox"/>
</m:FolderIds>
</m:GetFolder>`);
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
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(`\
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>AllProperties</t:BaseShape>
</m:ItemShape>
<m:IndexedPageItemView MaxEntriesReturned="3" Offset="0" BasePoint="Beginning"/>
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="inbox"/>
</m:ParentFolderIds>
<m:SortOrder>
<t:FieldOrder Order="Descending">
<t:FieldURI FieldURI="item:DateTimeReceived"/>
</t:FieldOrder>
</m:SortOrder>
</m:FindItem>`);
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 ---");
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
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)}`);
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (req.method === "GET") {
const transport = new SSEServerTransport("/message", res);
const server = buildServer();
await server.connect(transport);
transports.set(transport.sessionId, transport);
res.on("close", () => {
transports.delete(transport.sessionId);
});
return;
}
if (req.method === "POST" && url.pathname === "/message") {
const sessionId = url.searchParams.get("sessionId") || "";
const transport = transports.get(sessionId);
if (!transport) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Session not found");
return;
}
await transport.handlePostMessage(req, res);
return;
}
res.writeHead(404);
res.end("Not found");
});
const port = Number(options.port);
httpServer.listen(port, options.host);
console.error(
`Exchange MCP Server running via SSE on http://${options.host}:${port}`,
);
}
async function main() {
if (!env.EWS_PASSWORD) {
console.error("Set EWS_PASSWORD environment variable");
process.exit(1);
}
const transportType: string = options.transport;
try {
await getFolder();
await getLastMessages();
} catch (err) {
console.error("Error:", err);
switch (transportType) {
case "stdio":
await runStdio();
break;
case "sse":
await runSSE();
break;
default:
console.error(`Unsupported transport: "${transportType}". Use "stdio" or "sse".`);
process.exit(1);
}
}
main();
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+103
View File
@@ -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<string, string>;
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;
}
+1685 -2
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -2,12 +2,20 @@
"name": "exchange-mcp",
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "tsx main.ts",
"format": "dprint fmt"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"commander": "^15.0.0",
"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"
}
}
+94
View File
@@ -0,0 +1,94 @@
import { z } from "zod/v4";
import { EwsClient, loadConfig, saveConfig, clearConfig } from "../ews_client.ts";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
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 result = await client.verifyConnection();
if (!result.ok) {
return {
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}` }) }],
};
} 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 result = await client.verifyConnection();
return {
content: [{
type: "text" as const,
text: JSON.stringify({
authenticated: result.ok,
email: config.email,
serverUrl: config.serverUrl,
error: result.error,
}),
}],
};
} 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." }) }],
};
},
);
}
+228
View File
@@ -0,0 +1,228 @@
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());
}
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 = `\
<m:Restriction>
<t:IsEqualTo>
<t:FieldURI FieldURI="message:IsRead"/>
<t:FieldURIOrConstant>
<t:Constant Value="false"/>
</t:FieldURIOrConstant>
</t:IsEqualTo>
</m: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<string, string> = {
subject: "item:Subject",
body: "item:Body",
from: "message:From",
};
function containsExpression(fieldUri: string, value: string): string {
return `\
<t:Contains ContainmentMode="Substring" ContainmentComparison="IgnoreCase">
<t:FieldURI FieldURI="${fieldUri}"/>
<t:Constant Value="${value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")}"/>
</t:Contains>`;
}
let restriction: string;
if (searchScope === "all") {
restriction = `\
<m:Restriction>
<t:Or>
${containsExpression("item:Subject", query)}
${containsExpression("item:Body", query)}
</t:Or>
</m:Restriction>`;
} else {
const fieldUri = fieldUriMap[searchScope];
if (!fieldUri) {
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: `unsupported search_scope: ${searchScope}`, results: [] }) }],
};
}
restriction = `\
<m:Restriction>
${containsExpression(fieldUri, query)}
</m:Restriction>`;
}
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;
}
+30
View File
@@ -0,0 +1,30 @@
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(
"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) }) }],
};
}
},
);
}