This commit is contained in:
2026-07-09 15:46:13 +03:00
parent 91f83da567
commit 509e49e374
6 changed files with 143 additions and 51 deletions
+23 -8
View File
@@ -537,12 +537,16 @@ ${restriction}
const changesXml = itemIds.map((id) => {
const updatesXml = updates.map((u) => {
const val = typeof u.value === "boolean" ? (u.value ? "true" : "false") : u.value;
const val = typeof u.value === "boolean"
? (u.value ? "true" : "false")
: u.value;
return `\
<t:SetItemField>
<t:FieldURI FieldURI="${u.fieldUri}"/>
<t:Message>
<t:${propName(u.fieldUri)}>${String(val)}</t:${propName(u.fieldUri)}>
<t:${propName(u.fieldUri)}>${String(val)}</t:${
propName(u.fieldUri)
}>
</t:Message>
</t:SetItemField>`;
}).join("\n");
@@ -573,7 +577,9 @@ ${changesXml}
// ── GetAttachment ───────────────────────────────────────────────────────
async getAttachment(attachmentId: string): Promise<{ content: Buffer; name: string; contentType: string }> {
async getAttachment(
attachmentId: string,
): Promise<{ content: Buffer; name: string; contentType: string }> {
const soap = buildSoapEnvelope(`\
<m:GetAttachment>
<m:AttachmentIds>
@@ -645,13 +651,17 @@ ${changesXml}
endDate: string,
requestedView: string = "DetailedMerged",
): Promise<any> {
const mailboxDataXml = emails.map((email) => `\
const mailboxDataXml = emails.map((email) =>
`\
<t:MailboxData>
<t:Email>
<t:Address>${email.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</t:Address>
<t:Address>${
email.replace(/&/g, "&amp;").replace(/</g, "&lt;")
}</t:Address>
</t:Email>
<t:AttendeeType>Required</t:AttendeeType>
</t:MailboxData>`).join("\n");
</t:MailboxData>`
).join("\n");
const soap = `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
@@ -684,10 +694,15 @@ ${mailboxDataXml}
// ── ResolveNames (directory search) ─────────────────────────────────────
async resolveNames(query: string, fullContact: boolean = true): Promise<any[]> {
async resolveNames(
query: string,
fullContact: boolean = true,
): Promise<any[]> {
const soap = buildSoapEnvelope(`\
<m:ResolveNames ReturnFullContactData="${fullContact}" SearchScope="ActiveDirectoryContacts">
<m:UnresolvedEntry>${query.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</m:UnresolvedEntry>
<m:UnresolvedEntry>${
query.replace(/&/g, "&amp;").replace(/</g, "&lt;")
}</m:UnresolvedEntry>
</m:ResolveNames>`);
const data = await this.soapRequest(
+2 -2
View File
@@ -6,10 +6,10 @@ import crypto from "node:crypto";
import http from "node:http";
import { initDataDir } from "./ews_client.ts";
import { registerAuthTools } from "./tools/auth.ts";
import { registerAvailabilityTools } from "./tools/availability.ts";
import { registerCalendarTools } from "./tools/calendar.ts";
import { registerEmailTools } from "./tools/email.ts";
import { registerFolderTools } from "./tools/folders.ts";
import { registerCalendarTools } from "./tools/calendar.ts";
import { registerAvailabilityTools } from "./tools/availability.ts";
import { registerPeopleTools } from "./tools/people.ts";
const program = new Command()
+75 -25
View File
@@ -9,10 +9,18 @@ export function registerAvailabilityTools(server: McpServer): void {
description: "Find free time slots in your own calendar",
inputSchema: z.object({
startDate: z.string().describe("Start date in YYYY-MM-DD format"),
endDate: z.string().optional().describe("End date in YYYY-MM-DD format (defaults to startDate)"),
durationMinutes: z.number().default(30).describe("Minimum slot duration in minutes (default 30)"),
startHour: z.number().default(9).describe("Working day start hour (0-23, default 9)"),
endHour: z.number().default(18).describe("Working day end hour (0-23, default 18)"),
endDate: z.string().optional().describe(
"End date in YYYY-MM-DD format (defaults to startDate)",
),
durationMinutes: z.number().default(30).describe(
"Minimum slot duration in minutes (default 30)",
),
startHour: z.number().default(9).describe(
"Working day start hour (0-23, default 9)",
),
endHour: z.number().default(18).describe(
"Working day end hour (0-23, default 18)",
),
}),
},
async ({ startDate, endDate, durationMinutes, startHour, endHour }) => {
@@ -27,9 +35,13 @@ export function registerAvailabilityTools(server: McpServer): void {
ed,
);
const body = data?.Envelope?.Body?.GetUserAvailabilityResponse ?? data?.Envelope?.Body ?? {};
const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse ?? [];
const responses = Array.isArray(freeBusyArray) ? freeBusyArray : [freeBusyArray];
const body = data?.Envelope?.Body?.GetUserAvailabilityResponse
?? data?.Envelope?.Body ?? {};
const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse
?? [];
const responses = Array.isArray(freeBusyArray)
? freeBusyArray
: [freeBusyArray];
const busyPeriods: { start: Date; end: Date }[] = [];
@@ -63,7 +75,10 @@ export function registerAvailabilityTools(server: McpServer): void {
);
return {
content: [{ type: "text" as const, text: JSON.stringify({ freeSlots }) }],
content: [{
type: "text" as const,
text: JSON.stringify({ freeSlots }),
}],
};
} catch (error: any) {
return {
@@ -81,25 +96,43 @@ export function registerAvailabilityTools(server: McpServer): void {
{
description: "Find meeting times that work for multiple people",
inputSchema: z.object({
emails: z.string().describe("Comma-separated email addresses of attendees"),
emails: z.string().describe(
"Comma-separated email addresses of attendees",
),
startDate: z.string().describe("Start date in YYYY-MM-DD format"),
endDate: z.string().optional().describe("End date in YYYY-MM-DD format (defaults to startDate)"),
durationMinutes: z.number().default(30).describe("Minimum slot duration in minutes (default 30)"),
startHour: z.number().default(9).describe("Working day start hour (0-23, default 9)"),
endHour: z.number().default(18).describe("Working day end hour (0-23, default 18)"),
endDate: z.string().optional().describe(
"End date in YYYY-MM-DD format (defaults to startDate)",
),
durationMinutes: z.number().default(30).describe(
"Minimum slot duration in minutes (default 30)",
),
startHour: z.number().default(9).describe(
"Working day start hour (0-23, default 9)",
),
endHour: z.number().default(18).describe(
"Working day end hour (0-23, default 18)",
),
}),
},
async ({ emails, startDate, endDate, durationMinutes, startHour, endHour }) => {
async (
{ emails, startDate, endDate, durationMinutes, startHour, endHour },
) => {
try {
const client = new EwsClient(loadConfig());
const emailList = emails.split(",").map((e) => e.trim()).filter(Boolean);
const emailList = emails.split(",").map((e) => e.trim()).filter(
Boolean,
);
const ed = endDate || startDate;
const data = await client.getUserAvailability(emailList, startDate, ed);
const body = data?.Envelope?.Body?.GetUserAvailabilityResponse ?? data?.Envelope?.Body ?? {};
const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse ?? [];
const responses = Array.isArray(freeBusyArray) ? freeBusyArray : [freeBusyArray];
const body = data?.Envelope?.Body?.GetUserAvailabilityResponse
?? data?.Envelope?.Body ?? {};
const freeBusyArray = body?.FreeBusyResponseArray?.FreeBusyResponse
?? [];
const responses = Array.isArray(freeBusyArray)
? freeBusyArray
: [freeBusyArray];
const allBusy: { start: Date; end: Date }[] = [];
const attendeeInfo: any[] = [];
@@ -114,8 +147,14 @@ export function registerAvailabilityTools(server: McpServer): void {
const startTime = new Date(`${startDate}T00:00:00`);
const busyPeriods = parseFreeBusyString(mergedFb, startTime);
const busyCount = busyPeriods.length;
const freeCount = mergedFb.split("").filter((c: string) => c === "0").length;
attendeeInfo.push({ email, busySlots: busyCount, freeSlots: freeCount });
const freeCount = mergedFb.split("").filter((c: string) =>
c === "0"
).length;
attendeeInfo.push({
email,
busySlots: busyCount,
freeSlots: freeCount,
});
allBusy.push(...busyPeriods);
} else {
const calEvents = fbView?.CalendarEventArray?.CalendarEvent ?? [];
@@ -125,7 +164,10 @@ export function registerAvailabilityTools(server: McpServer): void {
const startStr = ev.StartTime ?? "";
const endStr = ev.EndTime ?? "";
if (startStr && endStr) {
allBusy.push({ start: new Date(startStr), end: new Date(endStr) });
allBusy.push({
start: new Date(startStr),
end: new Date(endStr),
});
}
}
}
@@ -184,10 +226,14 @@ function parseFreeBusyString(
return periods;
}
function mergeBusyPeriods(periods: { start: Date; end: Date }[]): { start: Date; end: Date }[] {
function mergeBusyPeriods(
periods: { start: Date; end: Date }[],
): { start: Date; end: Date }[] {
if (!periods.length) return [];
const sorted = [...periods].sort((a, b) => a.start.getTime() - b.start.getTime());
const sorted = [...periods].sort((a, b) =>
a.start.getTime() - b.start.getTime()
);
const merged: { start: Date; end: Date }[] = [{ ...sorted[0] }];
for (let i = 1; i < sorted.length; i++) {
@@ -210,7 +256,10 @@ function findFreeSlots(
endHour: number,
durationMinutes: number,
): Record<string, { start: string; end: string; durationMinutes: number }[]> {
const result: Record<string, { start: string; end: string; durationMinutes: number }[]> = {};
const result: Record<
string,
{ start: string; end: string; durationMinutes: number }[]
> = {};
const current = new Date(startDate);
current.setDate(current.getDate());
@@ -237,7 +286,8 @@ function findFreeSlots(
const merged = mergeBusyPeriods(dayBusy);
const slots: { start: string; end: string; durationMinutes: number }[] = [];
const slots: { start: string; end: string; durationMinutes: number }[] =
[];
let cursor = new Date(dayStart);
for (const bp of merged) {
+23 -9
View File
@@ -1,7 +1,7 @@
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod/v4";
import fs from "node:fs";
import path from "node:path";
import { z } from "zod/v4";
import { EwsClient, loadConfig } from "../ews_client.ts";
export function registerCalendarTools(server: McpServer): void {
@@ -34,10 +34,13 @@ export function registerCalendarTools(server: McpServer): void {
start: item.Start ?? "",
end: item.End ?? "",
location: item.Location ?? item.EnhancedLocation?.DisplayName ?? "",
isAllDay: item.IsAllDayEvent === "true" || item.IsAllDayEvent === true,
isCancelled: item.IsCancelled === "true" || item.IsCancelled === true,
isAllDay: item.IsAllDayEvent === "true"
|| item.IsAllDayEvent === true,
isCancelled: item.IsCancelled === "true"
|| item.IsCancelled === true,
isMeeting: true,
isRecurring: item.IsRecurring === "true" || item.IsRecurring === true,
isRecurring: item.IsRecurring === "true"
|| item.IsRecurring === true,
organizer: "",
organizerEmail: "",
myResponse: item.MyResponseType ?? "",
@@ -61,7 +64,10 @@ export function registerCalendarTools(server: McpServer): void {
}
return {
content: [{ type: "text" as const, text: JSON.stringify({ events, count: events.length }) }],
content: [{
type: "text" as const,
text: JSON.stringify({ events, count: events.length }),
}],
};
} catch (error: any) {
return {
@@ -77,9 +83,12 @@ export function registerCalendarTools(server: McpServer): void {
server.registerTool(
"download_event_attachments",
{
description: "Download all file attachments from a calendar event to disk",
description:
"Download all file attachments from a calendar event to disk",
inputSchema: z.object({
itemId: z.string().describe("The Exchange ItemId of the calendar event"),
itemId: z.string().describe(
"The Exchange ItemId of the calendar event",
),
targetFolder: z.string().default("/tmp/attachments").describe(
"Local directory to save files (default /tmp/attachments)",
),
@@ -130,7 +139,9 @@ export function registerCalendarTools(server: McpServer): void {
let counter = 1;
let finalName = filename;
while (usedNames.has(finalName.toLowerCase())) {
finalName = extPart ? `${namePart}_${counter}${extPart}` : `${namePart}_${counter}`;
finalName = extPart
? `${namePart}_${counter}${extPart}`
: `${namePart}_${counter}`;
counter++;
}
usedNames.add(finalName.toLowerCase());
@@ -145,7 +156,10 @@ export function registerCalendarTools(server: McpServer): void {
contentType: result.contentType,
});
} catch (e: any) {
errors.push({ name: att.name || "unknown", error: e.message ?? String(e) });
errors.push({
name: att.name || "unknown",
error: e.message ?? String(e),
});
}
}
+18 -6
View File
@@ -1,7 +1,7 @@
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod/v4";
import fs from "node:fs";
import path from "node:path";
import { z } from "zod/v4";
import { EwsClient, loadConfig } from "../ews_client.ts";
function getClient(): EwsClient {
@@ -293,8 +293,12 @@ export function registerEmailTools(server: McpServer): void {
{
description: "Mark one or more emails as read or unread",
inputSchema: z.object({
itemIds: z.array(z.string()).describe("List of Exchange ItemIds to update"),
isRead: z.boolean().default(true).describe("True to mark as read, False to mark as unread"),
itemIds: z.array(z.string()).describe(
"List of Exchange ItemIds to update",
),
isRead: z.boolean().default(true).describe(
"True to mark as read, False to mark as unread",
),
}),
},
async ({ itemIds, isRead }) => {
@@ -310,7 +314,10 @@ export function registerEmailTools(server: McpServer): void {
if (errors.length) {
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: errors.join("; ") }) }],
content: [{
type: "text" as const,
text: JSON.stringify({ error: errors.join("; ") }),
}],
};
}
@@ -391,7 +398,9 @@ export function registerEmailTools(server: McpServer): void {
let counter = 1;
let finalName = filename;
while (usedNames.has(finalName.toLowerCase())) {
finalName = extPart ? `${namePart}_${counter}${extPart}` : `${namePart}_${counter}`;
finalName = extPart
? `${namePart}_${counter}${extPart}`
: `${namePart}_${counter}`;
counter++;
}
usedNames.add(finalName.toLowerCase());
@@ -406,7 +415,10 @@ export function registerEmailTools(server: McpServer): void {
contentType: result.contentType,
});
} catch (e: any) {
errors.push({ name: att.name || "unknown", error: e.message ?? String(e) });
errors.push({
name: att.name || "unknown",
error: e.message ?? String(e),
});
}
}
+2 -1
View File
@@ -52,7 +52,8 @@ export function registerPeopleTools(server: McpServer): void {
const addrList = Array.isArray(addrs) ? addrs : [addrs];
for (const a of addrList) {
if (a?.Key === "Business") {
const parts = [a.Street, a.City, a.PostalCode, a.CountryOrRegion].filter(Boolean);
const parts = [a.Street, a.City, a.PostalCode, a.CountryOrRegion]
.filter(Boolean);
if (parts.length) {
person.address = parts.join(", ");
}