@chrischall/office-outlook-mcp 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -4
- package/dist/bundle.js +447 -27
- package/dist/index.js +4 -0
- package/dist/tools/drafts.js +219 -0
- package/dist/tools/folders.js +279 -0
- package/dist/tools/mail.js +1 -1
- package/dist/tools/rules.js +3 -3
- package/dist/tools/writes.js +4 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Outlook / Microsoft 365 — mail, calendar, contacts and tasks via natural language",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.11.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Outlook (Microsoft 365)",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "MCP server for Outlook / Microsoft 365 — triage unread mail, reply, answer meeting invites and schedule meetings, plus folders, calendar, contacts and tasks, with confirmation before anything is sent",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.11.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "office-outlook-mcp",
|
|
3
3
|
"displayName": "Outlook (Microsoft 365)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"description": "Outlook / Microsoft 365 for Claude — triage unread mail, reply, answer meeting invites and schedule meetings, plus folders, calendar, contacts and tasks, with confirmation before anything is sent, using your signed-in browser session",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Hall",
|
package/README.md
CHANGED
|
@@ -134,14 +134,16 @@ Everything is optional — with nothing set, the server captures from the browse
|
|
|
134
134
|
`outlook_list_categories`, `outlook_list_tasks`, `outlook_list_rules`
|
|
135
135
|
|
|
136
136
|
**Write** (ask you to confirm first — see [Confirmations](#confirmations)) —
|
|
137
|
-
`outlook_send_message`, `outlook_create_draft`, `outlook_mark_read`,
|
|
137
|
+
`outlook_send_message`, `outlook_create_draft`, `outlook_update_draft`, `outlook_send_draft`, `outlook_mark_read`,
|
|
138
138
|
`outlook_update_message`, `outlook_move_message`, `outlook_create_event`,
|
|
139
139
|
`outlook_update_event`, `outlook_respond_to_invite`, `outlook_delete_event`,
|
|
140
140
|
`outlook_reply`, `outlook_create_rule`, `outlook_update_rule`,
|
|
141
|
-
`outlook_delete_rule`, `outlook_set_auto_reply
|
|
141
|
+
`outlook_delete_rule`, `outlook_set_auto_reply`, `outlook_create_folder`,
|
|
142
|
+
`outlook_update_folder` (rename or move), `outlook_delete_folder`
|
|
143
|
+
(permanent: the folder does not go to Deleted Items).
|
|
142
144
|
Some skip the confirmation because nothing reaches anyone else:
|
|
143
|
-
`outlook_create_draft` (the draft stays in Drafts until you send it
|
|
144
|
-
Outlook), `outlook_reply` with `draftOnly: true` (the reply stays in Drafts),
|
|
145
|
+
`outlook_create_draft` (the draft stays in Drafts until you send it with
|
|
146
|
+
`outlook_send_draft` or from Outlook), `outlook_create_folder` (an empty folder is easily deleted), `outlook_reply` with `draftOnly: true` (the reply stays in Drafts),
|
|
145
147
|
`outlook_respond_to_invite` with `sendResponse: false`, and
|
|
146
148
|
`outlook_delete_event` on your own appointment with no attendees or on a
|
|
147
149
|
meeting the organizer already cancelled (in each, only your calendar
|
package/dist/bundle.js
CHANGED
|
@@ -10588,8 +10588,8 @@ var init_pt = __esm({
|
|
|
10588
10588
|
case "not_multiple_of":
|
|
10589
10589
|
return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${issue2.divisor}`;
|
|
10590
10590
|
case "unrecognized_keys": {
|
|
10591
|
-
const
|
|
10592
|
-
return `Chave${
|
|
10591
|
+
const plural3 = issue2.keys.length > 1 ? "s" : "";
|
|
10592
|
+
return `Chave${plural3} inv\xE1lida${plural3}: ${joinValues(issue2.keys, ", ")}`;
|
|
10593
10593
|
}
|
|
10594
10594
|
case "invalid_key":
|
|
10595
10595
|
return `Entrada inv\xE1lida n${translateOriginWithArticle(issue2.origin, "definite")}`;
|
|
@@ -10739,8 +10739,8 @@ var init_pt_BR = __esm({
|
|
|
10739
10739
|
case "not_multiple_of":
|
|
10740
10740
|
return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${issue2.divisor}`;
|
|
10741
10741
|
case "unrecognized_keys": {
|
|
10742
|
-
const
|
|
10743
|
-
return `Chave${
|
|
10742
|
+
const plural3 = issue2.keys.length > 1 ? "s" : "";
|
|
10743
|
+
return `Chave${plural3} inv\xE1lida${plural3}: ${joinValues(issue2.keys, ", ")}`;
|
|
10744
10744
|
}
|
|
10745
10745
|
case "invalid_key":
|
|
10746
10746
|
return `Entrada inv\xE1lida n${translateOriginWithArticle(issue2.origin, "definite")}`;
|
|
@@ -37164,11 +37164,11 @@ async function confirmWrite(ctx, options) {
|
|
|
37164
37164
|
}
|
|
37165
37165
|
const query = request?.query ? Object.fromEntries(Object.entries(request.query).filter(([, v]) => v !== void 0)) : void 0;
|
|
37166
37166
|
const hasQuery = query !== void 0 && Object.keys(query).length > 0;
|
|
37167
|
-
const
|
|
37167
|
+
const shown3 = willSend !== void 0 ? willSend : request?.body !== void 0 ? request.body : payload;
|
|
37168
37168
|
const preview = {
|
|
37169
37169
|
...summary === void 0 ? {} : { action: summary },
|
|
37170
37170
|
...request === void 0 ? {} : { method: request.method, path: request.path },
|
|
37171
|
-
...
|
|
37171
|
+
...shown3 === void 0 ? {} : { willSend: shown3 },
|
|
37172
37172
|
...hasQuery ? { willSendQuery: query } : {},
|
|
37173
37173
|
...extra
|
|
37174
37174
|
};
|
|
@@ -39293,7 +39293,7 @@ var init_version = __esm({
|
|
|
39293
39293
|
"src/version.ts"() {
|
|
39294
39294
|
"use strict";
|
|
39295
39295
|
PACKAGE_NAME = "office-outlook-mcp";
|
|
39296
|
-
VERSION = "0.
|
|
39296
|
+
VERSION = "0.11.0";
|
|
39297
39297
|
}
|
|
39298
39298
|
});
|
|
39299
39299
|
|
|
@@ -60041,20 +60041,20 @@ var require_ms = __commonJS({
|
|
|
60041
60041
|
function fmtLong(ms) {
|
|
60042
60042
|
var msAbs = Math.abs(ms);
|
|
60043
60043
|
if (msAbs >= d) {
|
|
60044
|
-
return
|
|
60044
|
+
return plural3(ms, msAbs, d, "day");
|
|
60045
60045
|
}
|
|
60046
60046
|
if (msAbs >= h) {
|
|
60047
|
-
return
|
|
60047
|
+
return plural3(ms, msAbs, h, "hour");
|
|
60048
60048
|
}
|
|
60049
60049
|
if (msAbs >= m) {
|
|
60050
|
-
return
|
|
60050
|
+
return plural3(ms, msAbs, m, "minute");
|
|
60051
60051
|
}
|
|
60052
60052
|
if (msAbs >= s) {
|
|
60053
|
-
return
|
|
60053
|
+
return plural3(ms, msAbs, s, "second");
|
|
60054
60054
|
}
|
|
60055
60055
|
return ms + " ms";
|
|
60056
60056
|
}
|
|
60057
|
-
function
|
|
60057
|
+
function plural3(ms, msAbs, n, name3) {
|
|
60058
60058
|
var isPlural = msAbs >= n * 1.5;
|
|
60059
60059
|
return Math.round(ms / n) + " " + name3 + (isPlural ? "s" : "");
|
|
60060
60060
|
}
|
|
@@ -68629,12 +68629,12 @@ var TITLE_FLOOR = 24;
|
|
|
68629
68629
|
var cut = (s, n) => s.length > n ? `${s.slice(0, Math.max(n - 1, 0))}\u2026` : s;
|
|
68630
68630
|
function briefLine(title, date5, status) {
|
|
68631
68631
|
const raw = flat(title) ?? "(no subject)";
|
|
68632
|
-
const
|
|
68632
|
+
const shown3 = raw.startsWith("-- ") || raw.startsWith("\\") ? `\\${raw}` : raw;
|
|
68633
68633
|
const sep2 = ` \u2014 ${date5} \u2014 `;
|
|
68634
68634
|
const room = BRIEF_MAX - sep2.length;
|
|
68635
|
-
if (
|
|
68636
|
-
const titleRoom = Math.max(room - status.length, Math.min(
|
|
68637
|
-
const t = cut(
|
|
68635
|
+
if (shown3.length + status.length <= room) return `${shown3}${sep2}${status}`;
|
|
68636
|
+
const titleRoom = Math.max(room - status.length, Math.min(shown3.length, TITLE_FLOOR));
|
|
68637
|
+
const t = cut(shown3, titleRoom);
|
|
68638
68638
|
return cut(`${t}${sep2}${cut(status, room - t.length)}`, BRIEF_MAX);
|
|
68639
68639
|
}
|
|
68640
68640
|
var BRIEF_MORE = "-- more";
|
|
@@ -69921,7 +69921,7 @@ function registerWriteTools(server, client2) {
|
|
|
69921
69921
|
server.registerTool(
|
|
69922
69922
|
"outlook_create_draft",
|
|
69923
69923
|
{
|
|
69924
|
-
description: "Create a draft message in the Drafts folder without sending it. Returns the created draft, which can be
|
|
69924
|
+
description: "Create a draft message in the Drafts folder without sending it. Returns the created draft, which can be changed with outlook_update_draft and sent with outlook_send_draft. It does not ask to confirm: a draft reaches nobody until it is sent. Calling it twice leaves two drafts. " + TIMEOUT_SENTENCE,
|
|
69925
69925
|
// Self-only and additive, like outlook_reply with draftOnly: nothing is
|
|
69926
69926
|
// overwritten or sent. Not idempotent: a repeat creates a second draft.
|
|
69927
69927
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -70054,8 +70054,8 @@ function registerWriteTools(server, client2) {
|
|
|
70054
70054
|
}
|
|
70055
70055
|
const echoed = await client2.write("PATCH", pathOf(id), payload);
|
|
70056
70056
|
const after = echoed && typeof echoed === "object" ? echoed : await client2.get(`${pathOf(id)}?$select=IsRead,Flag,Categories`) ?? {};
|
|
70057
|
-
const
|
|
70058
|
-
return
|
|
70057
|
+
const unchanged2 = unchangedFields(payload, after);
|
|
70058
|
+
return unchanged2.length ? { id, ok: false, error: `Outlook accepted the write but these did not change: ${unchanged2.join(", ")}.` } : { id, ok: true };
|
|
70059
70059
|
} catch (e) {
|
|
70060
70060
|
if (isCredentialFailure(e)) throw e;
|
|
70061
70061
|
return { id, ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -70258,28 +70258,28 @@ function registerWriteTools(server, client2) {
|
|
|
70258
70258
|
await client2.write("PATCH", path2, payload);
|
|
70259
70259
|
const writtenZone = (payload.Start ?? payload.End)?.TimeZone ?? tz;
|
|
70260
70260
|
const after = await readEvent(writtenZone);
|
|
70261
|
-
const
|
|
70262
|
-
if (payload.Subject !== void 0 && after?.Subject !== payload.Subject)
|
|
70261
|
+
const unchanged2 = [];
|
|
70262
|
+
if (payload.Subject !== void 0 && after?.Subject !== payload.Subject) unchanged2.push("Subject");
|
|
70263
70263
|
for (const key of ["Start", "End"]) {
|
|
70264
70264
|
const want = payload[key];
|
|
70265
|
-
if (want && trimTime2(after?.[key]?.DateTime) !== trimTime2(want.DateTime))
|
|
70265
|
+
if (want && trimTime2(after?.[key]?.DateTime) !== trimTime2(want.DateTime)) unchanged2.push(key);
|
|
70266
70266
|
}
|
|
70267
70267
|
if (payload.Location !== void 0 && after?.Location?.DisplayName !== args.location) {
|
|
70268
|
-
|
|
70268
|
+
unchanged2.push("Location");
|
|
70269
70269
|
}
|
|
70270
70270
|
if (payload.Attendees !== void 0) {
|
|
70271
70271
|
const want = payload.Attendees.map(lower).sort().join();
|
|
70272
70272
|
const got = (after?.Attendees ?? []).map(lower).sort().join();
|
|
70273
|
-
if (want !== got)
|
|
70273
|
+
if (want !== got) unchanged2.push("Attendees");
|
|
70274
70274
|
}
|
|
70275
70275
|
const joinUrl = joinUrlOf(after);
|
|
70276
|
-
if (addTeams && !joinUrl)
|
|
70276
|
+
if (addTeams && !joinUrl) unchanged2.push("Teams meeting");
|
|
70277
70277
|
return minifiedResult({
|
|
70278
|
-
updated:
|
|
70278
|
+
updated: unchanged2.length === 0,
|
|
70279
70279
|
Id: after?.Id ?? args.id,
|
|
70280
70280
|
changed: Object.keys(payload),
|
|
70281
70281
|
...joinUrl ? { JoinUrl: joinUrl } : {},
|
|
70282
|
-
...
|
|
70282
|
+
...unchanged2.length ? { warning: `Outlook accepted the update but these did not change: ${unchanged2.join(", ")}.` } : {}
|
|
70283
70283
|
});
|
|
70284
70284
|
}
|
|
70285
70285
|
);
|
|
@@ -71826,6 +71826,424 @@ function registerRuleTools(server, client2) {
|
|
|
71826
71826
|
);
|
|
71827
71827
|
}
|
|
71828
71828
|
|
|
71829
|
+
// src/tools/folders.ts
|
|
71830
|
+
init_zod();
|
|
71831
|
+
init_dist2();
|
|
71832
|
+
var ROOT = "msgfolderroot";
|
|
71833
|
+
var RULES_PATH2 = "/me/MailFolders/inbox/MessageRules";
|
|
71834
|
+
var folderPath = (id) => `/me/mailfolders/${encodeURIComponent(id)}`;
|
|
71835
|
+
var PROTECTED = /* @__PURE__ */ new Set([...WELL_KNOWN, ROOT]);
|
|
71836
|
+
var LIST_HINT = 'Pass a folder id from outlook_list_folders or a well-known name such as "inbox".';
|
|
71837
|
+
function refuseWellKnown(id, verb) {
|
|
71838
|
+
if (PROTECTED.has(id.toLowerCase())) {
|
|
71839
|
+
throw new McpToolError(`"${id}" is one of Outlook's own folders and cannot be ${verb}.`, {
|
|
71840
|
+
hint: "Pick a folder you created; list them with outlook_list_folders."
|
|
71841
|
+
});
|
|
71842
|
+
}
|
|
71843
|
+
}
|
|
71844
|
+
async function readFolder(client2, id) {
|
|
71845
|
+
try {
|
|
71846
|
+
return await client2.get(folderPath(id));
|
|
71847
|
+
} catch (e) {
|
|
71848
|
+
const status = errorStatusOf(e);
|
|
71849
|
+
if (status !== 400 && status !== 404) throw e;
|
|
71850
|
+
throw new McpToolError(`No mail folder matches "${id}".`, { hint: LIST_HINT });
|
|
71851
|
+
}
|
|
71852
|
+
}
|
|
71853
|
+
var shown = (f) => ({ ...compactFolder(f), Parent: f.ParentFolderId });
|
|
71854
|
+
var STILL_DELETING = "Outlook did not answer in time. A large folder keeps running its delete on the server for minutes after the request (about 75 messages a second), so it is probably still deleting. Check the folder's Total in outlook_list_folders: falling means it is working, gone means done. Do not resend the delete.";
|
|
71855
|
+
var plural2 = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
71856
|
+
var SUBTREE_CAP = 500;
|
|
71857
|
+
var CHILD_SELECT = "Id,TotalItemCount,UnreadItemCount,ChildFolderCount";
|
|
71858
|
+
async function subtree(client2, root) {
|
|
71859
|
+
const out = {
|
|
71860
|
+
total: root.TotalItemCount ?? 0,
|
|
71861
|
+
unread: root.UnreadItemCount ?? 0,
|
|
71862
|
+
subfolders: 0,
|
|
71863
|
+
ids: [root.Id],
|
|
71864
|
+
partial: false
|
|
71865
|
+
};
|
|
71866
|
+
const queue = (root.ChildFolderCount ?? 0) > 0 ? [{ id: root.Id, expected: root.ChildFolderCount ?? 0 }] : [];
|
|
71867
|
+
while (queue.length) {
|
|
71868
|
+
const { id: parent, expected } = queue.shift();
|
|
71869
|
+
if (out.subfolders >= SUBTREE_CAP) {
|
|
71870
|
+
out.partial = true;
|
|
71871
|
+
break;
|
|
71872
|
+
}
|
|
71873
|
+
let page;
|
|
71874
|
+
try {
|
|
71875
|
+
page = await client2.get(`${folderPath(parent)}/childfolders`, { query: { $select: CHILD_SELECT, $top: 100 } });
|
|
71876
|
+
} catch {
|
|
71877
|
+
out.partial = true;
|
|
71878
|
+
continue;
|
|
71879
|
+
}
|
|
71880
|
+
const kids = page.value ?? [];
|
|
71881
|
+
if (kids.length >= 100 || kids.length < expected) out.partial = true;
|
|
71882
|
+
for (const k of kids) {
|
|
71883
|
+
out.subfolders++;
|
|
71884
|
+
out.total += k.TotalItemCount ?? 0;
|
|
71885
|
+
out.unread += k.UnreadItemCount ?? 0;
|
|
71886
|
+
if (k.Id) {
|
|
71887
|
+
out.ids.push(k.Id);
|
|
71888
|
+
if ((k.ChildFolderCount ?? 0) > 0) queue.push({ id: k.Id, expected: k.ChildFolderCount ?? 0 });
|
|
71889
|
+
}
|
|
71890
|
+
}
|
|
71891
|
+
}
|
|
71892
|
+
return out;
|
|
71893
|
+
}
|
|
71894
|
+
function contents(t) {
|
|
71895
|
+
const atLeast = t.partial ? "at least " : "";
|
|
71896
|
+
const mail = `${atLeast}${plural2(t.total, "message")}${t.unread ? ` (${t.unread} unread)` : ""}`;
|
|
71897
|
+
return t.subfolders ? `${mail}, counting its ${atLeast}${plural2(t.subfolders, "subfolder")}` : mail;
|
|
71898
|
+
}
|
|
71899
|
+
async function rulesUsing(client2, ids) {
|
|
71900
|
+
const doomed = new Set(ids);
|
|
71901
|
+
try {
|
|
71902
|
+
const page = await client2.get(RULES_PATH2);
|
|
71903
|
+
return (page.value ?? []).filter((r) => doomed.has(r.Actions?.MoveToFolder ?? "") || doomed.has(r.Actions?.CopyToFolder ?? "")).map((r) => r.DisplayName ?? "(unnamed rule)");
|
|
71904
|
+
} catch {
|
|
71905
|
+
return [];
|
|
71906
|
+
}
|
|
71907
|
+
}
|
|
71908
|
+
function registerFolderTools(server, client2) {
|
|
71909
|
+
server.registerTool(
|
|
71910
|
+
"outlook_create_folder",
|
|
71911
|
+
{
|
|
71912
|
+
description: "Create a mail folder: at the top level of the mailbox by default, or inside `parent`. Nothing reaches anyone else and an empty folder is easily deleted, so this does not ask for confirmation. Point rules or moves at the returned Id. " + TIMEOUT_SENTENCE,
|
|
71913
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
71914
|
+
_meta: workMeta("change", { aliases: ["new folder", "add folder", "make folder"] }),
|
|
71915
|
+
inputSchema: external_exports.object({
|
|
71916
|
+
name: external_exports.string().min(1).describe("Folder name"),
|
|
71917
|
+
parent: external_exports.string().min(1).optional().describe('Folder id or well-known name (e.g. "inbox") to create it inside. Omit for the top level.')
|
|
71918
|
+
})
|
|
71919
|
+
},
|
|
71920
|
+
async ({ name: name3, parent }) => {
|
|
71921
|
+
const path2 = `${folderPath(parent ?? ROOT)}/childfolders`;
|
|
71922
|
+
let write;
|
|
71923
|
+
try {
|
|
71924
|
+
write = await writeOrUnknown2(() => client2.write("POST", path2, { DisplayName: name3 }), "outlook_list_folders");
|
|
71925
|
+
} catch (e) {
|
|
71926
|
+
const status = errorStatusOf(e);
|
|
71927
|
+
if (status === 409) {
|
|
71928
|
+
throw new McpToolError(`A folder named "${name3}" already exists there.`, {
|
|
71929
|
+
hint: "Use the existing one (see outlook_list_folders) or pick another name."
|
|
71930
|
+
});
|
|
71931
|
+
}
|
|
71932
|
+
if (parent !== void 0 && (status === 400 || status === 404)) {
|
|
71933
|
+
throw new McpToolError(`No mail folder matches "${parent}".`, { hint: LIST_HINT });
|
|
71934
|
+
}
|
|
71935
|
+
throw e;
|
|
71936
|
+
}
|
|
71937
|
+
if (!write.ok) return write.result;
|
|
71938
|
+
return minifiedResult({ created: true, ...shown(write.value ?? { DisplayName: name3 }) });
|
|
71939
|
+
}
|
|
71940
|
+
);
|
|
71941
|
+
server.registerTool(
|
|
71942
|
+
"outlook_update_folder",
|
|
71943
|
+
{
|
|
71944
|
+
description: "Rename a mail folder, move it under another folder, or both. Its mail and subfolders go with it, and rules that file into it keep working. Outlook's own folders (Inbox, Archive, \u2026) are refused. The result is verified by re-reading the folder. " + TIMEOUT_SENTENCE + " " + CONFIRM_FLOW_SENTENCE,
|
|
71945
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
71946
|
+
_meta: workMeta("change", { aliases: ["rename folder", "move folder"] }),
|
|
71947
|
+
inputSchema: external_exports.object({
|
|
71948
|
+
id: external_exports.string().min(1).describe("Folder Id from outlook_list_folders"),
|
|
71949
|
+
name: external_exports.string().min(1).optional().describe("New name"),
|
|
71950
|
+
parent: external_exports.string().min(1).optional().describe('Folder id or well-known name to move it into; "msgfolderroot" is the top level'),
|
|
71951
|
+
confirmToken: confirmTokenParam
|
|
71952
|
+
})
|
|
71953
|
+
},
|
|
71954
|
+
async ({ id, name: name3, parent, confirmToken }, ctx) => {
|
|
71955
|
+
if (name3 === void 0 && parent === void 0) {
|
|
71956
|
+
throw new McpToolError("Nothing to change on this folder.", { hint: "Pass `name`, `parent` or both." });
|
|
71957
|
+
}
|
|
71958
|
+
refuseWellKnown(id, "renamed or moved");
|
|
71959
|
+
if (parent !== void 0 && parent === id) {
|
|
71960
|
+
throw new McpToolError("A folder cannot be moved into itself.", { hint: "Pick a different `parent`." });
|
|
71961
|
+
}
|
|
71962
|
+
const current = await readFolder(client2, id);
|
|
71963
|
+
const destination = parent === void 0 ? void 0 : await folderId(client2, parent);
|
|
71964
|
+
const label = current.DisplayName ?? id;
|
|
71965
|
+
const changes = [
|
|
71966
|
+
...name3 !== void 0 ? [`rename it to "${name3}"`] : [],
|
|
71967
|
+
...parent !== void 0 ? [parent.toLowerCase() === ROOT ? "move it to the top level" : `move it into ${parent}`] : []
|
|
71968
|
+
].join(" and ");
|
|
71969
|
+
const gate = await confirmWrite(ctx, {
|
|
71970
|
+
tool: "outlook_update_folder",
|
|
71971
|
+
action: "mail.folder_update",
|
|
71972
|
+
message: `Review and confirm: ${changes}.`,
|
|
71973
|
+
summary: `Folder "${label}": ${changes}.`,
|
|
71974
|
+
account: void 0,
|
|
71975
|
+
target: id,
|
|
71976
|
+
// A rename and a move are separate calls; preview both, as sent.
|
|
71977
|
+
payload: [
|
|
71978
|
+
...name3 !== void 0 ? [{ method: "PATCH", path: folderPath(id), body: { DisplayName: name3 } }] : [],
|
|
71979
|
+
...destination !== void 0 ? [{ method: "POST", path: `${folderPath(id)}/move`, body: { DestinationId: destination } }] : []
|
|
71980
|
+
],
|
|
71981
|
+
confirmToken,
|
|
71982
|
+
preview: { current: shown(current) }
|
|
71983
|
+
});
|
|
71984
|
+
if (gate) return gate;
|
|
71985
|
+
if (name3 !== void 0) {
|
|
71986
|
+
const rename3 = await writeOrUnknown2(() => client2.write("PATCH", folderPath(id), { DisplayName: name3 }), "outlook_list_folders");
|
|
71987
|
+
if (!rename3.ok) return rename3.result;
|
|
71988
|
+
}
|
|
71989
|
+
let finalId = id;
|
|
71990
|
+
if (destination !== void 0) {
|
|
71991
|
+
const move = await writeOrUnknown2(
|
|
71992
|
+
() => client2.write("POST", `${folderPath(id)}/move`, { DestinationId: destination }),
|
|
71993
|
+
"outlook_list_folders"
|
|
71994
|
+
);
|
|
71995
|
+
if (!move.ok) return move.result;
|
|
71996
|
+
finalId = move.value?.Id ?? id;
|
|
71997
|
+
}
|
|
71998
|
+
const read = await readBack(() => client2.get(folderPath(finalId)));
|
|
71999
|
+
if (!read.ok) return minifiedResult({ updated: true, Id: finalId, ...unverified("outlook_list_folders") });
|
|
72000
|
+
const after = read.value;
|
|
72001
|
+
const stale = [
|
|
72002
|
+
...name3 !== void 0 && after.DisplayName !== name3 ? ["name"] : [],
|
|
72003
|
+
...destination !== void 0 && after.ParentFolderId !== destination ? ["location"] : []
|
|
72004
|
+
];
|
|
72005
|
+
return minifiedResult({
|
|
72006
|
+
updated: stale.length === 0,
|
|
72007
|
+
...shown(after),
|
|
72008
|
+
...stale.length ? { warning: `Outlook accepted the write but the ${stale.join(" and ")} did not change.` } : {}
|
|
72009
|
+
});
|
|
72010
|
+
}
|
|
72011
|
+
);
|
|
72012
|
+
server.registerTool(
|
|
72013
|
+
"outlook_delete_folder",
|
|
72014
|
+
{
|
|
72015
|
+
description: `Delete a mail folder together with every message and subfolder in it. This is permanent: the folder does NOT go to Deleted Items, so it cannot be restored from there. To keep the mail, move it out first (outlook_move_message). The confirmation totals the mail in the folder and every subfolder beneath it, and names any inbox rules that file into them (those rules stop working). Outlook's own folders are refused. The result is verified by re-reading the folder. A folder holding thousands of messages takes minutes to delete, so the call can come back with status "unknown" while Outlook keeps deleting: check its Total in outlook_list_folders falling, and never resend. ` + CONFIRM_FLOW_SENTENCE,
|
|
72016
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
72017
|
+
_meta: workMeta("change", { aliases: ["remove folder"] }),
|
|
72018
|
+
inputSchema: external_exports.object({
|
|
72019
|
+
id: external_exports.string().min(1).describe("Folder Id from outlook_list_folders"),
|
|
72020
|
+
confirmToken: confirmTokenParam
|
|
72021
|
+
})
|
|
72022
|
+
},
|
|
72023
|
+
async ({ id, confirmToken }, ctx) => {
|
|
72024
|
+
refuseWellKnown(id, "deleted");
|
|
72025
|
+
const current = await readFolder(client2, id);
|
|
72026
|
+
const label = current.DisplayName ?? id;
|
|
72027
|
+
const tree = await subtree(client2, { ...current, Id: current.Id ?? id });
|
|
72028
|
+
const rules = await rulesUsing(client2, tree.ids);
|
|
72029
|
+
const ruleNote = rules.length ? ` These inbox rules file mail into it or its subfolders and will stop working: ${rules.map((r) => `"${r}"`).join(", ")}.` : "";
|
|
72030
|
+
const gate = await confirmWrite(ctx, {
|
|
72031
|
+
tool: "outlook_delete_folder",
|
|
72032
|
+
action: "mail.folder_delete",
|
|
72033
|
+
message: `Review and confirm permanently deleting this folder with its ${contents(tree)} (it does not go to Deleted Items):${ruleNote}`,
|
|
72034
|
+
summary: `Permanently delete folder "${label}" with its ${contents(tree)}; it does not go to Deleted Items.${ruleNote}`,
|
|
72035
|
+
account: void 0,
|
|
72036
|
+
target: id,
|
|
72037
|
+
request: { method: "DELETE", path: folderPath(id) },
|
|
72038
|
+
confirmToken,
|
|
72039
|
+
preview: { current: shown(current), ...rules.length ? { rulesUsingIt: rules } : {} }
|
|
72040
|
+
});
|
|
72041
|
+
if (gate) return gate;
|
|
72042
|
+
let write;
|
|
72043
|
+
try {
|
|
72044
|
+
write = await writeOrUnknown2(() => client2.write("DELETE", folderPath(id)), "outlook_list_folders");
|
|
72045
|
+
} catch (e) {
|
|
72046
|
+
if (errorStatusOf(e) === 400 && /ErrorDeleteDistinguishedFolder/.test(String(e.message))) {
|
|
72047
|
+
throw new McpToolError(`"${label}" is one of Outlook's own folders and cannot be deleted.`, {
|
|
72048
|
+
hint: "Nothing was deleted. Leave it in place, or delete the folders you created inside it instead."
|
|
72049
|
+
});
|
|
72050
|
+
}
|
|
72051
|
+
throw e;
|
|
72052
|
+
}
|
|
72053
|
+
if (!write.ok) return minifiedResult({ status: "unknown", Id: id, warning: STILL_DELETING });
|
|
72054
|
+
const read = await readBack(() => client2.get(folderPath(id)));
|
|
72055
|
+
if (!read.ok && errorStatusOf(read.error) === 404) return minifiedResult({ deleted: true, Id: id });
|
|
72056
|
+
if (!read.ok) return minifiedResult({ deleted: true, Id: id, ...unverified("outlook_list_folders") });
|
|
72057
|
+
return minifiedResult({ deleted: false, Id: id, warning: "Outlook accepted the delete but the folder is still there." });
|
|
72058
|
+
}
|
|
72059
|
+
);
|
|
72060
|
+
}
|
|
72061
|
+
|
|
72062
|
+
// src/tools/drafts.ts
|
|
72063
|
+
init_zod();
|
|
72064
|
+
init_dist2();
|
|
72065
|
+
var DRAFT_SELECT = "Id,IsDraft,Subject,Body,ToRecipients,CcRecipients,BccRecipients,WebLink";
|
|
72066
|
+
var messagePath = (id) => `/me/messages/${encodeURIComponent(id)}`;
|
|
72067
|
+
var PREVIEW_BODY_MAX = 2e3;
|
|
72068
|
+
var addresses2 = (list) => (list ?? []).map((r) => r.EmailAddress?.Address ?? "");
|
|
72069
|
+
var sameAddresses = (a, b) => addresses2(a).map((x) => x.toLowerCase()).sort().join("\n") === addresses2(b).map((x) => x.toLowerCase()).sort().join("\n");
|
|
72070
|
+
var sameText = (a, b) => (a ?? "").replace(/\r\n/g, "\n").trim() === (b ?? "").replace(/\r\n/g, "\n").trim();
|
|
72071
|
+
function shown2(d) {
|
|
72072
|
+
return {
|
|
72073
|
+
Id: d.Id,
|
|
72074
|
+
Subject: d.Subject,
|
|
72075
|
+
To: addresses2(d.ToRecipients),
|
|
72076
|
+
Cc: addresses2(d.CcRecipients),
|
|
72077
|
+
Bcc: addresses2(d.BccRecipients),
|
|
72078
|
+
WebLink: d.WebLink
|
|
72079
|
+
};
|
|
72080
|
+
}
|
|
72081
|
+
function currentBody(d) {
|
|
72082
|
+
const content = d.Body?.Content ?? "";
|
|
72083
|
+
return content.length > PREVIEW_BODY_MAX ? `${content.slice(0, PREVIEW_BODY_MAX)}\u2026 (${content.length} characters in all)` : content;
|
|
72084
|
+
}
|
|
72085
|
+
function unchanged(payload, after) {
|
|
72086
|
+
const out = [];
|
|
72087
|
+
if (payload.Subject !== void 0 && after.Subject !== payload.Subject) out.push("Subject");
|
|
72088
|
+
if (payload.Body) {
|
|
72089
|
+
const ok = payload.Body.ContentType === "HTML" ? after.Body?.ContentType === "HTML" : after.Body?.ContentType === "Text" && sameText(after.Body?.Content, payload.Body.Content);
|
|
72090
|
+
if (!ok) out.push("Body");
|
|
72091
|
+
}
|
|
72092
|
+
if (payload.ToRecipients && !sameAddresses(after.ToRecipients, payload.ToRecipients)) out.push("To");
|
|
72093
|
+
if (payload.CcRecipients && !sameAddresses(after.CcRecipients, payload.CcRecipients)) out.push("Cc");
|
|
72094
|
+
if (payload.BccRecipients && !sameAddresses(after.BccRecipients, payload.BccRecipients)) out.push("Bcc");
|
|
72095
|
+
return out;
|
|
72096
|
+
}
|
|
72097
|
+
async function readDraft(client2, id) {
|
|
72098
|
+
let found;
|
|
72099
|
+
try {
|
|
72100
|
+
found = await client2.get(`${messagePath(id)}?$select=${DRAFT_SELECT}`);
|
|
72101
|
+
} catch (e) {
|
|
72102
|
+
const status = errorStatusOf(e);
|
|
72103
|
+
if (status !== 400 && status !== 404) throw e;
|
|
72104
|
+
throw new McpToolError(`No message matches "${id}".`, {
|
|
72105
|
+
hint: 'Pass a draft Id from outlook_create_draft, outlook_reply with draftOnly, or outlook_list_messages with folder "drafts".'
|
|
72106
|
+
});
|
|
72107
|
+
}
|
|
72108
|
+
if (found.IsDraft !== true) {
|
|
72109
|
+
throw new McpToolError(`"${found.Subject ?? id}" is not a draft, so it cannot be edited.`, {
|
|
72110
|
+
hint: "Only unsent drafts can be changed. To send something different, create a new draft or use outlook_send_message."
|
|
72111
|
+
});
|
|
72112
|
+
}
|
|
72113
|
+
return found;
|
|
72114
|
+
}
|
|
72115
|
+
var SEND_CHECK_TRIES = 8;
|
|
72116
|
+
function registerDraftTools(server, client2, opts = {}) {
|
|
72117
|
+
const delayMs = opts.sendCheckDelayMs ?? 1500;
|
|
72118
|
+
server.registerTool(
|
|
72119
|
+
"outlook_update_draft",
|
|
72120
|
+
{
|
|
72121
|
+
description: "Edit an unsent draft: its subject, body, and To, Cc or Bcc recipients. Only what you pass changes, and each field you pass replaces the current one: `body` replaces the WHOLE body (in a reply or forward draft that includes the quoted original), and `to: []` clears the To line. Nothing is sent; send it with outlook_send_draft. Messages that are not drafts are refused. The confirmation shows the draft as it is now, because the replaced text cannot be recovered. The result is checked against the draft Outlook returns. " + TIMEOUT_SENTENCE + " " + CONFIRM_FLOW_SENTENCE,
|
|
72122
|
+
// Overwrites text that is not kept anywhere else, so it is destructive
|
|
72123
|
+
// even though it reaches nobody. Repeating the same edit changes nothing.
|
|
72124
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
72125
|
+
_meta: workMeta("change", { aliases: ["edit draft", "change draft", "rewrite draft"] }),
|
|
72126
|
+
inputSchema: external_exports.object({
|
|
72127
|
+
id: external_exports.string().min(1).describe('Draft Id (from outlook_create_draft, outlook_reply draftOnly, or outlook_list_messages folder "drafts")'),
|
|
72128
|
+
subject: external_exports.string().optional().describe("New subject line"),
|
|
72129
|
+
body: external_exports.string().optional().describe("New body; replaces the whole current body"),
|
|
72130
|
+
html: external_exports.boolean().optional().describe("Treat `body` as HTML (default: plain text). Only with `body`."),
|
|
72131
|
+
to: recipientList.describe("New To recipients; replaces the list ([] clears it)"),
|
|
72132
|
+
cc: recipientList.describe("New Cc recipients; replaces the list ([] clears it)"),
|
|
72133
|
+
bcc: recipientList.describe("New Bcc recipients; replaces the list ([] clears it)"),
|
|
72134
|
+
confirmToken: confirmTokenParam
|
|
72135
|
+
})
|
|
72136
|
+
},
|
|
72137
|
+
async ({ id, subject, body, html, to, cc, bcc, confirmToken }, ctx) => {
|
|
72138
|
+
if (html !== void 0 && body === void 0) {
|
|
72139
|
+
throw new McpToolError("`html` only applies together with `body`.", { hint: "Pass the new `body` as well." });
|
|
72140
|
+
}
|
|
72141
|
+
const payload = {
|
|
72142
|
+
...subject !== void 0 ? { Subject: subject } : {},
|
|
72143
|
+
...body !== void 0 ? { Body: { ContentType: html === true ? "HTML" : "Text", Content: body } } : {},
|
|
72144
|
+
...to !== void 0 ? { ToRecipients: toRecipients(to) } : {},
|
|
72145
|
+
...cc !== void 0 ? { CcRecipients: toRecipients(cc) } : {},
|
|
72146
|
+
...bcc !== void 0 ? { BccRecipients: toRecipients(bcc) } : {}
|
|
72147
|
+
};
|
|
72148
|
+
if (Object.keys(payload).length === 0) {
|
|
72149
|
+
throw new McpToolError("Nothing to change on this draft.", {
|
|
72150
|
+
hint: "Pass at least one of subject, body, to, cc or bcc."
|
|
72151
|
+
});
|
|
72152
|
+
}
|
|
72153
|
+
const current = await readDraft(client2, id);
|
|
72154
|
+
const fields = [
|
|
72155
|
+
...subject !== void 0 ? ["subject"] : [],
|
|
72156
|
+
...body !== void 0 ? ["whole body"] : [],
|
|
72157
|
+
...to !== void 0 ? ["To list"] : [],
|
|
72158
|
+
...cc !== void 0 ? ["Cc list"] : [],
|
|
72159
|
+
...bcc !== void 0 ? ["Bcc list"] : []
|
|
72160
|
+
];
|
|
72161
|
+
const path2 = messagePath(id);
|
|
72162
|
+
const gate = await confirmWrite(ctx, {
|
|
72163
|
+
tool: "outlook_update_draft",
|
|
72164
|
+
action: "mail.draft_update",
|
|
72165
|
+
message: `Review and confirm this draft edit. It will replace the draft's ${fields.join(", ")}; the current draft is shown below. Nothing is sent.`,
|
|
72166
|
+
summary: `Edit draft "${current.Subject ?? id}": replace its ${fields.join(", ")}. Nothing is sent.`,
|
|
72167
|
+
account: void 0,
|
|
72168
|
+
target: id,
|
|
72169
|
+
request: { method: "PATCH", path: path2, body: payload },
|
|
72170
|
+
confirmToken,
|
|
72171
|
+
preview: { current: { ...shown2(current), Body: currentBody(current) } }
|
|
72172
|
+
});
|
|
72173
|
+
if (gate) return gate;
|
|
72174
|
+
const write = await writeOrUnknown2(() => client2.write("PATCH", path2, payload), "Drafts");
|
|
72175
|
+
if (!write.ok) return write.result;
|
|
72176
|
+
const echoed = write.value;
|
|
72177
|
+
let after;
|
|
72178
|
+
if (echoed && typeof echoed === "object" && echoed.Id) {
|
|
72179
|
+
after = echoed;
|
|
72180
|
+
} else {
|
|
72181
|
+
const read = await readBack(() => client2.get(`${path2}?$select=${DRAFT_SELECT}`));
|
|
72182
|
+
if (!read.ok) return minifiedResult({ updated: true, Id: id, ...unverified("the draft in Drafts") });
|
|
72183
|
+
after = read.value;
|
|
72184
|
+
}
|
|
72185
|
+
const stale = unchanged(payload, after);
|
|
72186
|
+
return minifiedResult({
|
|
72187
|
+
updated: stale.length === 0,
|
|
72188
|
+
...shown2(after),
|
|
72189
|
+
...stale.length ? { warning: `Outlook accepted the edit but these did not change: ${stale.join(", ")}.` } : {}
|
|
72190
|
+
});
|
|
72191
|
+
}
|
|
72192
|
+
);
|
|
72193
|
+
server.registerTool(
|
|
72194
|
+
"outlook_send_draft",
|
|
72195
|
+
{
|
|
72196
|
+
description: "Send an existing draft as it stands: its subject, body, recipients and attachments go out unchanged. To change it first, use outlook_update_draft. Refuses a message that is not a draft, or a draft with no recipients. The confirmation shows exactly what will be sent and to whom. Once sent, Outlook files the copy in Sent Items under a new id. Outlook finishes a send in the background, so the result waits a few seconds for the draft to leave Drafts; if it has not, the result says to check Sent Items, never to resend. " + TIMEOUT_SENTENCE + " " + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
|
|
72197
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
72198
|
+
_meta: workMeta("send", { aliases: ["send draft", "send the draft"] }),
|
|
72199
|
+
inputSchema: external_exports.object({
|
|
72200
|
+
id: external_exports.string().min(1).describe('Draft Id (from outlook_create_draft, outlook_reply draftOnly, or outlook_list_messages folder "drafts")'),
|
|
72201
|
+
confirmToken: confirmTokenParam
|
|
72202
|
+
})
|
|
72203
|
+
},
|
|
72204
|
+
async ({ id, confirmToken }, ctx) => {
|
|
72205
|
+
const current = await readDraft(client2, id);
|
|
72206
|
+
const view = shown2(current);
|
|
72207
|
+
const recipients = [...view.To, ...view.Cc, ...view.Bcc];
|
|
72208
|
+
if (recipients.length === 0) {
|
|
72209
|
+
throw new McpToolError(`Draft "${current.Subject ?? id}" has no recipients, so there is no one to send it to.`, {
|
|
72210
|
+
hint: "Add To, Cc or Bcc recipients with outlook_update_draft, then send it."
|
|
72211
|
+
});
|
|
72212
|
+
}
|
|
72213
|
+
const path2 = `${messagePath(id)}/send`;
|
|
72214
|
+
const gate = await confirmWrite(ctx, {
|
|
72215
|
+
tool: "outlook_send_draft",
|
|
72216
|
+
action: "mail.send_draft",
|
|
72217
|
+
message: "Review and confirm this email before it is sent:",
|
|
72218
|
+
summary: `Send draft "${current.Subject ?? ""}" to ${recipients.join(", ")}`,
|
|
72219
|
+
account: void 0,
|
|
72220
|
+
target: id,
|
|
72221
|
+
request: { method: "POST", path: path2 },
|
|
72222
|
+
// The send carries no body: what goes out is the stored draft, so that is what is shown and bound.
|
|
72223
|
+
willSend: { Subject: current.Subject, To: view.To, Cc: view.Cc, Bcc: view.Bcc, Body: currentBody(current) },
|
|
72224
|
+
confirmToken
|
|
72225
|
+
});
|
|
72226
|
+
if (gate) return gate;
|
|
72227
|
+
const write = await writeOrUnknown2(() => client2.write("POST", path2), "Sent Items");
|
|
72228
|
+
if (!write.ok) return write.result;
|
|
72229
|
+
const summary = { subject: current.Subject, recipients };
|
|
72230
|
+
for (let attempt = 1; attempt <= SEND_CHECK_TRIES; attempt++) {
|
|
72231
|
+
const read = await readBack(() => client2.get(`${messagePath(id)}?$select=Id,IsDraft`));
|
|
72232
|
+
if (!read.ok && errorStatusOf(read.error) === 404) return minifiedResult({ sent: true, ...summary });
|
|
72233
|
+
if (!read.ok) return minifiedResult({ sent: true, ...summary, ...unverified("Sent Items") });
|
|
72234
|
+
if (read.value.IsDraft !== true) return minifiedResult({ sent: true, ...summary });
|
|
72235
|
+
if (attempt < SEND_CHECK_TRIES) await new Promise((r) => setTimeout(r, delayMs));
|
|
72236
|
+
}
|
|
72237
|
+
return minifiedResult({
|
|
72238
|
+
sent: true,
|
|
72239
|
+
verified: false,
|
|
72240
|
+
...summary,
|
|
72241
|
+
warning: `Outlook accepted the send, but the draft had not left Drafts after ${Math.round(delayMs * (SEND_CHECK_TRIES - 1) / 1e3)} s; Outlook is probably still delivering it. Check Sent Items before doing anything else, and do not send it again unless it is missing there.`
|
|
72242
|
+
});
|
|
72243
|
+
}
|
|
72244
|
+
);
|
|
72245
|
+
}
|
|
72246
|
+
|
|
71829
72247
|
// src/tools/copilot.ts
|
|
71830
72248
|
init_zod();
|
|
71831
72249
|
init_dist2();
|
|
@@ -72349,6 +72767,8 @@ await runMcp({
|
|
|
72349
72767
|
registerWriteTools,
|
|
72350
72768
|
registerExportTools,
|
|
72351
72769
|
registerRuleTools,
|
|
72770
|
+
registerFolderTools,
|
|
72771
|
+
registerDraftTools,
|
|
72352
72772
|
(server) => registerCopilotTools(server, copilot, client),
|
|
72353
72773
|
registerHealthcheckTool
|
|
72354
72774
|
]
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { registerWriteTools } from './tools/writes.js';
|
|
|
9
9
|
import { registerHealthcheckTool } from './tools/healthcheck.js';
|
|
10
10
|
import { registerExportTools } from './tools/export.js';
|
|
11
11
|
import { registerRuleTools } from './tools/rules.js';
|
|
12
|
+
import { registerFolderTools } from './tools/folders.js';
|
|
13
|
+
import { registerDraftTools } from './tools/drafts.js';
|
|
12
14
|
import { registerCopilotTools } from './tools/copilot.js';
|
|
13
15
|
import { CopilotClient } from './copilot/client.js';
|
|
14
16
|
loadDotenvSafely();
|
|
@@ -45,6 +47,8 @@ await runMcp({
|
|
|
45
47
|
registerWriteTools,
|
|
46
48
|
registerExportTools,
|
|
47
49
|
registerRuleTools,
|
|
50
|
+
registerFolderTools,
|
|
51
|
+
registerDraftTools,
|
|
48
52
|
(server) => registerCopilotTools(server, copilot, client),
|
|
49
53
|
registerHealthcheckTool,
|
|
50
54
|
],
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, errorStatusOf, McpToolError, minifiedResult, } from '@chrischall/mcp-utils';
|
|
3
|
+
import { workMeta } from './_hints.js';
|
|
4
|
+
import { readBack, unverified } from './rules.js';
|
|
5
|
+
import { recipientList, TIMEOUT_SENTENCE, toRecipients, writeOrUnknown } from './writes.js';
|
|
6
|
+
const DRAFT_SELECT = 'Id,IsDraft,Subject,Body,ToRecipients,CcRecipients,BccRecipients,WebLink';
|
|
7
|
+
const messagePath = (id) => `/me/messages/${encodeURIComponent(id)}`;
|
|
8
|
+
/** The preview shows the body being replaced; past this it is cut, and says so. */
|
|
9
|
+
const PREVIEW_BODY_MAX = 2000;
|
|
10
|
+
const addresses = (list) => (list ?? []).map((r) => r.EmailAddress?.Address ?? '');
|
|
11
|
+
const sameAddresses = (a, b) => addresses(a).map((x) => x.toLowerCase()).sort().join('\n') === addresses(b).map((x) => x.toLowerCase()).sort().join('\n');
|
|
12
|
+
/** Outlook may store line breaks as CRLF; the words are what must match. */
|
|
13
|
+
const sameText = (a, b) => (a ?? '').replace(/\r\n/g, '\n').trim() === (b ?? '').replace(/\r\n/g, '\n').trim();
|
|
14
|
+
function shown(d) {
|
|
15
|
+
return {
|
|
16
|
+
Id: d.Id,
|
|
17
|
+
Subject: d.Subject,
|
|
18
|
+
To: addresses(d.ToRecipients),
|
|
19
|
+
Cc: addresses(d.CcRecipients),
|
|
20
|
+
Bcc: addresses(d.BccRecipients),
|
|
21
|
+
WebLink: d.WebLink,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function currentBody(d) {
|
|
25
|
+
const content = d.Body?.Content ?? '';
|
|
26
|
+
return content.length > PREVIEW_BODY_MAX ? `${content.slice(0, PREVIEW_BODY_MAX)}… (${content.length} characters in all)` : content;
|
|
27
|
+
}
|
|
28
|
+
/** Fields the PATCH set that the draft Outlook returned does not show. */
|
|
29
|
+
function unchanged(payload, after) {
|
|
30
|
+
const out = [];
|
|
31
|
+
if (payload.Subject !== undefined && after.Subject !== payload.Subject)
|
|
32
|
+
out.push('Subject');
|
|
33
|
+
if (payload.Body) {
|
|
34
|
+
// Outlook wraps an HTML body in its own <html><head>…, so only its type can be checked.
|
|
35
|
+
const ok = payload.Body.ContentType === 'HTML'
|
|
36
|
+
? after.Body?.ContentType === 'HTML'
|
|
37
|
+
: after.Body?.ContentType === 'Text' && sameText(after.Body?.Content, payload.Body.Content);
|
|
38
|
+
if (!ok)
|
|
39
|
+
out.push('Body');
|
|
40
|
+
}
|
|
41
|
+
if (payload.ToRecipients && !sameAddresses(after.ToRecipients, payload.ToRecipients))
|
|
42
|
+
out.push('To');
|
|
43
|
+
if (payload.CcRecipients && !sameAddresses(after.CcRecipients, payload.CcRecipients))
|
|
44
|
+
out.push('Cc');
|
|
45
|
+
if (payload.BccRecipients && !sameAddresses(after.BccRecipients, payload.BccRecipients))
|
|
46
|
+
out.push('Bcc');
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
async function readDraft(client, id) {
|
|
50
|
+
let found;
|
|
51
|
+
try {
|
|
52
|
+
found = await client.get(`${messagePath(id)}?$select=${DRAFT_SELECT}`);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
const status = errorStatusOf(e);
|
|
56
|
+
if (status !== 400 && status !== 404)
|
|
57
|
+
throw e;
|
|
58
|
+
throw new McpToolError(`No message matches "${id}".`, {
|
|
59
|
+
hint: 'Pass a draft Id from outlook_create_draft, outlook_reply with draftOnly, or outlook_list_messages with folder "drafts".',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (found.IsDraft !== true) {
|
|
63
|
+
throw new McpToolError(`"${found.Subject ?? id}" is not a draft, so it cannot be edited.`, {
|
|
64
|
+
hint: 'Only unsent drafts can be changed. To send something different, create a new draft or use outlook_send_message.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* How long to watch for a sent draft to leave Drafts. Outlook answers the send
|
|
71
|
+
* with 202 and delivers in the background: live 2026-11-10 the draft was still
|
|
72
|
+
* readable as a draft straight after the 202 although the mail arrived.
|
|
73
|
+
*/
|
|
74
|
+
const SEND_CHECK_TRIES = 8;
|
|
75
|
+
export function registerDraftTools(server, client, opts = {}) {
|
|
76
|
+
const delayMs = opts.sendCheckDelayMs ?? 1500;
|
|
77
|
+
server.registerTool('outlook_update_draft', {
|
|
78
|
+
description: 'Edit an unsent draft: its subject, body, and To, Cc or Bcc recipients. Only what you pass changes, and each field you pass replaces the current one: `body` replaces the WHOLE body (in a reply or forward draft that includes the quoted original), and `to: []` clears the To line. Nothing is sent; send it with outlook_send_draft. Messages that are not drafts are refused. The confirmation shows the draft as it is now, because the replaced text cannot be recovered. The result is checked against the draft Outlook returns. ' +
|
|
79
|
+
TIMEOUT_SENTENCE +
|
|
80
|
+
' ' +
|
|
81
|
+
CONFIRM_FLOW_SENTENCE,
|
|
82
|
+
// Overwrites text that is not kept anywhere else, so it is destructive
|
|
83
|
+
// even though it reaches nobody. Repeating the same edit changes nothing.
|
|
84
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
85
|
+
_meta: workMeta('change', { aliases: ['edit draft', 'change draft', 'rewrite draft'] }),
|
|
86
|
+
inputSchema: z.object({
|
|
87
|
+
id: z.string().min(1).describe('Draft Id (from outlook_create_draft, outlook_reply draftOnly, or outlook_list_messages folder "drafts")'),
|
|
88
|
+
subject: z.string().optional().describe('New subject line'),
|
|
89
|
+
body: z.string().optional().describe('New body; replaces the whole current body'),
|
|
90
|
+
html: z.boolean().optional().describe('Treat `body` as HTML (default: plain text). Only with `body`.'),
|
|
91
|
+
to: recipientList.describe('New To recipients; replaces the list ([] clears it)'),
|
|
92
|
+
cc: recipientList.describe('New Cc recipients; replaces the list ([] clears it)'),
|
|
93
|
+
bcc: recipientList.describe('New Bcc recipients; replaces the list ([] clears it)'),
|
|
94
|
+
confirmToken: confirmTokenParam,
|
|
95
|
+
}),
|
|
96
|
+
}, async ({ id, subject, body, html, to, cc, bcc, confirmToken }, ctx) => {
|
|
97
|
+
if (html !== undefined && body === undefined) {
|
|
98
|
+
throw new McpToolError('`html` only applies together with `body`.', { hint: 'Pass the new `body` as well.' });
|
|
99
|
+
}
|
|
100
|
+
const payload = {
|
|
101
|
+
...(subject !== undefined ? { Subject: subject } : {}),
|
|
102
|
+
...(body !== undefined ? { Body: { ContentType: html === true ? 'HTML' : 'Text', Content: body } } : {}),
|
|
103
|
+
...(to !== undefined ? { ToRecipients: toRecipients(to) } : {}),
|
|
104
|
+
...(cc !== undefined ? { CcRecipients: toRecipients(cc) } : {}),
|
|
105
|
+
...(bcc !== undefined ? { BccRecipients: toRecipients(bcc) } : {}),
|
|
106
|
+
};
|
|
107
|
+
if (Object.keys(payload).length === 0) {
|
|
108
|
+
throw new McpToolError('Nothing to change on this draft.', {
|
|
109
|
+
hint: 'Pass at least one of subject, body, to, cc or bcc.',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const current = await readDraft(client, id);
|
|
113
|
+
const fields = [
|
|
114
|
+
...(subject !== undefined ? ['subject'] : []),
|
|
115
|
+
...(body !== undefined ? ['whole body'] : []),
|
|
116
|
+
...(to !== undefined ? ['To list'] : []),
|
|
117
|
+
...(cc !== undefined ? ['Cc list'] : []),
|
|
118
|
+
...(bcc !== undefined ? ['Bcc list'] : []),
|
|
119
|
+
];
|
|
120
|
+
const path = messagePath(id);
|
|
121
|
+
const gate = await confirmWrite(ctx, {
|
|
122
|
+
tool: 'outlook_update_draft',
|
|
123
|
+
action: 'mail.draft_update',
|
|
124
|
+
message: `Review and confirm this draft edit. It will replace the draft's ${fields.join(', ')}; the current draft is shown below. Nothing is sent.`,
|
|
125
|
+
summary: `Edit draft "${current.Subject ?? id}": replace its ${fields.join(', ')}. Nothing is sent.`,
|
|
126
|
+
account: undefined,
|
|
127
|
+
target: id,
|
|
128
|
+
request: { method: 'PATCH', path, body: payload },
|
|
129
|
+
confirmToken,
|
|
130
|
+
preview: { current: { ...shown(current), Body: currentBody(current) } },
|
|
131
|
+
});
|
|
132
|
+
if (gate)
|
|
133
|
+
return gate;
|
|
134
|
+
const write = await writeOrUnknown(() => client.write('PATCH', path, payload), 'Drafts');
|
|
135
|
+
if (!write.ok)
|
|
136
|
+
return write.result;
|
|
137
|
+
// Outlook answers the PATCH with the updated draft; re-read only when it did not.
|
|
138
|
+
const echoed = write.value;
|
|
139
|
+
let after;
|
|
140
|
+
if (echoed && typeof echoed === 'object' && echoed.Id) {
|
|
141
|
+
after = echoed;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const read = await readBack(() => client.get(`${path}?$select=${DRAFT_SELECT}`));
|
|
145
|
+
if (!read.ok)
|
|
146
|
+
return minifiedResult({ updated: true, Id: id, ...unverified('the draft in Drafts') });
|
|
147
|
+
after = read.value;
|
|
148
|
+
}
|
|
149
|
+
const stale = unchanged(payload, after);
|
|
150
|
+
return minifiedResult({
|
|
151
|
+
updated: stale.length === 0,
|
|
152
|
+
...shown(after),
|
|
153
|
+
...(stale.length ? { warning: `Outlook accepted the edit but these did not change: ${stale.join(', ')}.` } : {}),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
server.registerTool('outlook_send_draft', {
|
|
157
|
+
description: 'Send an existing draft as it stands: its subject, body, recipients and attachments go out unchanged. To change it first, use outlook_update_draft. Refuses a message that is not a draft, or a draft with no recipients. The confirmation shows exactly what will be sent and to whom. Once sent, Outlook files the copy in Sent Items under a new id. Outlook finishes a send in the background, so the result waits a few seconds for the draft to leave Drafts; if it has not, the result says to check Sent Items, never to resend. ' +
|
|
158
|
+
TIMEOUT_SENTENCE +
|
|
159
|
+
' ' +
|
|
160
|
+
CONFIRM_FLOW_SENTENCE +
|
|
161
|
+
' ' +
|
|
162
|
+
CONFIRM_INJECTION_RULE,
|
|
163
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
164
|
+
_meta: workMeta('send', { aliases: ['send draft', 'send the draft'] }),
|
|
165
|
+
inputSchema: z.object({
|
|
166
|
+
id: z.string().min(1).describe('Draft Id (from outlook_create_draft, outlook_reply draftOnly, or outlook_list_messages folder "drafts")'),
|
|
167
|
+
confirmToken: confirmTokenParam,
|
|
168
|
+
}),
|
|
169
|
+
}, async ({ id, confirmToken }, ctx) => {
|
|
170
|
+
const current = await readDraft(client, id);
|
|
171
|
+
const view = shown(current);
|
|
172
|
+
const recipients = [...view.To, ...view.Cc, ...view.Bcc];
|
|
173
|
+
if (recipients.length === 0) {
|
|
174
|
+
throw new McpToolError(`Draft "${current.Subject ?? id}" has no recipients, so there is no one to send it to.`, {
|
|
175
|
+
hint: 'Add To, Cc or Bcc recipients with outlook_update_draft, then send it.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const path = `${messagePath(id)}/send`;
|
|
179
|
+
const gate = await confirmWrite(ctx, {
|
|
180
|
+
tool: 'outlook_send_draft',
|
|
181
|
+
action: 'mail.send_draft',
|
|
182
|
+
message: 'Review and confirm this email before it is sent:',
|
|
183
|
+
summary: `Send draft "${current.Subject ?? ''}" to ${recipients.join(', ')}`,
|
|
184
|
+
account: undefined,
|
|
185
|
+
target: id,
|
|
186
|
+
request: { method: 'POST', path },
|
|
187
|
+
// The send carries no body: what goes out is the stored draft, so that is what is shown and bound.
|
|
188
|
+
willSend: { Subject: current.Subject, To: view.To, Cc: view.Cc, Bcc: view.Bcc, Body: currentBody(current) },
|
|
189
|
+
confirmToken,
|
|
190
|
+
});
|
|
191
|
+
if (gate)
|
|
192
|
+
return gate;
|
|
193
|
+
const write = await writeOrUnknown(() => client.write('POST', path), 'Sent Items');
|
|
194
|
+
if (!write.ok)
|
|
195
|
+
return write.result;
|
|
196
|
+
const summary = { subject: current.Subject, recipients };
|
|
197
|
+
// Sending moves the draft out of Drafts, so its old id stops resolving,
|
|
198
|
+
// but only once Outlook has finished: watch for that for a few seconds.
|
|
199
|
+
// Outlook accepted the send, so nothing here ever reports "not sent": a
|
|
200
|
+
// resend on that word would mail everyone twice.
|
|
201
|
+
for (let attempt = 1; attempt <= SEND_CHECK_TRIES; attempt++) {
|
|
202
|
+
const read = await readBack(() => client.get(`${messagePath(id)}?$select=Id,IsDraft`));
|
|
203
|
+
if (!read.ok && errorStatusOf(read.error) === 404)
|
|
204
|
+
return minifiedResult({ sent: true, ...summary });
|
|
205
|
+
if (!read.ok)
|
|
206
|
+
return minifiedResult({ sent: true, ...summary, ...unverified('Sent Items') });
|
|
207
|
+
if (read.value.IsDraft !== true)
|
|
208
|
+
return minifiedResult({ sent: true, ...summary });
|
|
209
|
+
if (attempt < SEND_CHECK_TRIES)
|
|
210
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
211
|
+
}
|
|
212
|
+
return minifiedResult({
|
|
213
|
+
sent: true,
|
|
214
|
+
verified: false,
|
|
215
|
+
...summary,
|
|
216
|
+
warning: `Outlook accepted the send, but the draft had not left Drafts after ${Math.round((delayMs * (SEND_CHECK_TRIES - 1)) / 1000)} s; Outlook is probably still delivering it. Check Sent Items before doing anything else, and do not send it again unless it is missing there.`,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CONFIRM_FLOW_SENTENCE, confirmTokenParam, confirmWrite, errorStatusOf, McpToolError, minifiedResult, } from '@chrischall/mcp-utils';
|
|
3
|
+
import { compactFolder } from '../view.js';
|
|
4
|
+
import { workMeta } from './_hints.js';
|
|
5
|
+
import { WELL_KNOWN } from './mail.js';
|
|
6
|
+
import { folderId, readBack, unverified } from './rules.js';
|
|
7
|
+
import { TIMEOUT_SENTENCE, writeOrUnknown } from './writes.js';
|
|
8
|
+
/** The mailbox's top level: the parent of Inbox, Archive and the folders beside them. */
|
|
9
|
+
const ROOT = 'msgfolderroot';
|
|
10
|
+
const RULES_PATH = '/me/MailFolders/inbox/MessageRules';
|
|
11
|
+
const folderPath = (id) => `/me/mailfolders/${encodeURIComponent(id)}`;
|
|
12
|
+
const PROTECTED = new Set([...WELL_KNOWN, ROOT]);
|
|
13
|
+
const LIST_HINT = 'Pass a folder id from outlook_list_folders or a well-known name such as "inbox".';
|
|
14
|
+
/** Outlook's own folders cannot be renamed, moved or deleted, so refuse them by name before any request. */
|
|
15
|
+
function refuseWellKnown(id, verb) {
|
|
16
|
+
if (PROTECTED.has(id.toLowerCase())) {
|
|
17
|
+
throw new McpToolError(`"${id}" is one of Outlook's own folders and cannot be ${verb}.`, {
|
|
18
|
+
hint: 'Pick a folder you created; list them with outlook_list_folders.',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readFolder(client, id) {
|
|
23
|
+
try {
|
|
24
|
+
return await client.get(folderPath(id));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
const status = errorStatusOf(e);
|
|
28
|
+
if (status !== 400 && status !== 404)
|
|
29
|
+
throw e;
|
|
30
|
+
throw new McpToolError(`No mail folder matches "${id}".`, { hint: LIST_HINT });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const shown = (f) => ({ ...compactFolder(f), Parent: f.ParentFolderId });
|
|
34
|
+
/**
|
|
35
|
+
* Outlook deletes a big folder's mail after the DELETE is accepted, at about 75
|
|
36
|
+
* messages a second (live 2026-11-10: 13,370 took ~3 minutes, 63,466 ~14), so
|
|
37
|
+
* the call outlives the client timeout. A resend while it runs would only
|
|
38
|
+
* restart a delete that is already happening.
|
|
39
|
+
*/
|
|
40
|
+
const STILL_DELETING = 'Outlook did not answer in time. A large folder keeps running its delete on the server for minutes after the request (about 75 messages a second), so it is probably still deleting. Check the folder\'s Total in outlook_list_folders: falling means it is working, gone means done. Do not resend the delete.';
|
|
41
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
42
|
+
/** Past this many folders the walk stops and the counts read "at least". */
|
|
43
|
+
const SUBTREE_CAP = 500;
|
|
44
|
+
const CHILD_SELECT = 'Id,TotalItemCount,UnreadItemCount,ChildFolderCount';
|
|
45
|
+
/**
|
|
46
|
+
* A folder's own TotalItemCount leaves out its subfolders, and a delete takes
|
|
47
|
+
* them all: live 2026-11-10 a parent read "0 messages" over 2,725. So the
|
|
48
|
+
* confirmation walks the tree. A failed or capped walk is reported as a lower
|
|
49
|
+
* bound rather than blocking the delete.
|
|
50
|
+
*/
|
|
51
|
+
async function subtree(client, root) {
|
|
52
|
+
const out = {
|
|
53
|
+
total: root.TotalItemCount ?? 0,
|
|
54
|
+
unread: root.UnreadItemCount ?? 0,
|
|
55
|
+
subfolders: 0,
|
|
56
|
+
ids: [root.Id],
|
|
57
|
+
partial: false,
|
|
58
|
+
};
|
|
59
|
+
const queue = (root.ChildFolderCount ?? 0) > 0 ? [{ id: root.Id, expected: root.ChildFolderCount ?? 0 }] : [];
|
|
60
|
+
while (queue.length) {
|
|
61
|
+
const { id: parent, expected } = queue.shift();
|
|
62
|
+
if (out.subfolders >= SUBTREE_CAP) {
|
|
63
|
+
out.partial = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
let page;
|
|
67
|
+
try {
|
|
68
|
+
page = await client.get(`${folderPath(parent)}/childfolders`, { query: { $select: CHILD_SELECT, $top: 100 } });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
out.partial = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const kids = page.value ?? [];
|
|
75
|
+
// Hidden subfolders count in ChildFolderCount but are not listed (live: Conversation History).
|
|
76
|
+
if (kids.length >= 100 || kids.length < expected)
|
|
77
|
+
out.partial = true;
|
|
78
|
+
for (const k of kids) {
|
|
79
|
+
out.subfolders++;
|
|
80
|
+
out.total += k.TotalItemCount ?? 0;
|
|
81
|
+
out.unread += k.UnreadItemCount ?? 0;
|
|
82
|
+
if (k.Id) {
|
|
83
|
+
out.ids.push(k.Id);
|
|
84
|
+
if ((k.ChildFolderCount ?? 0) > 0)
|
|
85
|
+
queue.push({ id: k.Id, expected: k.ChildFolderCount ?? 0 });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/** What goes with a folder when it is deleted, in words. */
|
|
92
|
+
function contents(t) {
|
|
93
|
+
const atLeast = t.partial ? 'at least ' : '';
|
|
94
|
+
const mail = `${atLeast}${plural(t.total, 'message')}${t.unread ? ` (${t.unread} unread)` : ''}`;
|
|
95
|
+
return t.subfolders ? `${mail}, counting its ${atLeast}${plural(t.subfolders, 'subfolder')}` : mail;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Rules that file mail into this folder or any folder beneath it. Losing the folder breaks them, so the
|
|
99
|
+
* confirmation names them. A failed read only loses the warning, not the delete.
|
|
100
|
+
*/
|
|
101
|
+
async function rulesUsing(client, ids) {
|
|
102
|
+
const doomed = new Set(ids);
|
|
103
|
+
try {
|
|
104
|
+
const page = await client.get(RULES_PATH);
|
|
105
|
+
return (page.value ?? [])
|
|
106
|
+
.filter((r) => doomed.has(r.Actions?.MoveToFolder ?? '') || doomed.has(r.Actions?.CopyToFolder ?? ''))
|
|
107
|
+
.map((r) => r.DisplayName ?? '(unnamed rule)');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function registerFolderTools(server, client) {
|
|
114
|
+
server.registerTool('outlook_create_folder', {
|
|
115
|
+
description: 'Create a mail folder: at the top level of the mailbox by default, or inside `parent`. Nothing reaches anyone else and an empty folder is easily deleted, so this does not ask for confirmation. Point rules or moves at the returned Id. ' +
|
|
116
|
+
TIMEOUT_SENTENCE,
|
|
117
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
118
|
+
_meta: workMeta('change', { aliases: ['new folder', 'add folder', 'make folder'] }),
|
|
119
|
+
inputSchema: z.object({
|
|
120
|
+
name: z.string().min(1).describe('Folder name'),
|
|
121
|
+
parent: z
|
|
122
|
+
.string()
|
|
123
|
+
.min(1)
|
|
124
|
+
.optional()
|
|
125
|
+
.describe('Folder id or well-known name (e.g. "inbox") to create it inside. Omit for the top level.'),
|
|
126
|
+
}),
|
|
127
|
+
}, async ({ name, parent }) => {
|
|
128
|
+
const path = `${folderPath(parent ?? ROOT)}/childfolders`;
|
|
129
|
+
let write;
|
|
130
|
+
try {
|
|
131
|
+
write = await writeOrUnknown(() => client.write('POST', path, { DisplayName: name }), 'outlook_list_folders');
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
const status = errorStatusOf(e);
|
|
135
|
+
if (status === 409) {
|
|
136
|
+
throw new McpToolError(`A folder named "${name}" already exists there.`, {
|
|
137
|
+
hint: 'Use the existing one (see outlook_list_folders) or pick another name.',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (parent !== undefined && (status === 400 || status === 404)) {
|
|
141
|
+
throw new McpToolError(`No mail folder matches "${parent}".`, { hint: LIST_HINT });
|
|
142
|
+
}
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
if (!write.ok)
|
|
146
|
+
return write.result;
|
|
147
|
+
return minifiedResult({ created: true, ...shown(write.value ?? { DisplayName: name }) });
|
|
148
|
+
});
|
|
149
|
+
server.registerTool('outlook_update_folder', {
|
|
150
|
+
description: 'Rename a mail folder, move it under another folder, or both. Its mail and subfolders go with it, and rules that file into it keep working. Outlook\'s own folders (Inbox, Archive, …) are refused. The result is verified by re-reading the folder. ' +
|
|
151
|
+
TIMEOUT_SENTENCE +
|
|
152
|
+
' ' +
|
|
153
|
+
CONFIRM_FLOW_SENTENCE,
|
|
154
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
155
|
+
_meta: workMeta('change', { aliases: ['rename folder', 'move folder'] }),
|
|
156
|
+
inputSchema: z.object({
|
|
157
|
+
id: z.string().min(1).describe('Folder Id from outlook_list_folders'),
|
|
158
|
+
name: z.string().min(1).optional().describe('New name'),
|
|
159
|
+
parent: z
|
|
160
|
+
.string()
|
|
161
|
+
.min(1)
|
|
162
|
+
.optional()
|
|
163
|
+
.describe('Folder id or well-known name to move it into; "msgfolderroot" is the top level'),
|
|
164
|
+
confirmToken: confirmTokenParam,
|
|
165
|
+
}),
|
|
166
|
+
}, async ({ id, name, parent, confirmToken }, ctx) => {
|
|
167
|
+
if (name === undefined && parent === undefined) {
|
|
168
|
+
throw new McpToolError('Nothing to change on this folder.', { hint: 'Pass `name`, `parent` or both.' });
|
|
169
|
+
}
|
|
170
|
+
refuseWellKnown(id, 'renamed or moved');
|
|
171
|
+
if (parent !== undefined && parent === id) {
|
|
172
|
+
throw new McpToolError('A folder cannot be moved into itself.', { hint: 'Pick a different `parent`.' });
|
|
173
|
+
}
|
|
174
|
+
const current = await readFolder(client, id);
|
|
175
|
+
const destination = parent === undefined ? undefined : await folderId(client, parent);
|
|
176
|
+
const label = current.DisplayName ?? id;
|
|
177
|
+
const changes = [
|
|
178
|
+
...(name !== undefined ? [`rename it to "${name}"`] : []),
|
|
179
|
+
...(parent !== undefined ? [parent.toLowerCase() === ROOT ? 'move it to the top level' : `move it into ${parent}`] : []),
|
|
180
|
+
].join(' and ');
|
|
181
|
+
const gate = await confirmWrite(ctx, {
|
|
182
|
+
tool: 'outlook_update_folder',
|
|
183
|
+
action: 'mail.folder_update',
|
|
184
|
+
message: `Review and confirm: ${changes}.`,
|
|
185
|
+
summary: `Folder "${label}": ${changes}.`,
|
|
186
|
+
account: undefined,
|
|
187
|
+
target: id,
|
|
188
|
+
// A rename and a move are separate calls; preview both, as sent.
|
|
189
|
+
payload: [
|
|
190
|
+
...(name !== undefined ? [{ method: 'PATCH', path: folderPath(id), body: { DisplayName: name } }] : []),
|
|
191
|
+
...(destination !== undefined
|
|
192
|
+
? [{ method: 'POST', path: `${folderPath(id)}/move`, body: { DestinationId: destination } }]
|
|
193
|
+
: []),
|
|
194
|
+
],
|
|
195
|
+
confirmToken,
|
|
196
|
+
preview: { current: shown(current) },
|
|
197
|
+
});
|
|
198
|
+
if (gate)
|
|
199
|
+
return gate;
|
|
200
|
+
if (name !== undefined) {
|
|
201
|
+
const rename = await writeOrUnknown(() => client.write('PATCH', folderPath(id), { DisplayName: name }), 'outlook_list_folders');
|
|
202
|
+
if (!rename.ok)
|
|
203
|
+
return rename.result;
|
|
204
|
+
}
|
|
205
|
+
let finalId = id;
|
|
206
|
+
if (destination !== undefined) {
|
|
207
|
+
const move = await writeOrUnknown(() => client.write('POST', `${folderPath(id)}/move`, { DestinationId: destination }), 'outlook_list_folders');
|
|
208
|
+
if (!move.ok)
|
|
209
|
+
return move.result;
|
|
210
|
+
finalId = move.value?.Id ?? id;
|
|
211
|
+
}
|
|
212
|
+
const read = await readBack(() => client.get(folderPath(finalId)));
|
|
213
|
+
if (!read.ok)
|
|
214
|
+
return minifiedResult({ updated: true, Id: finalId, ...unverified('outlook_list_folders') });
|
|
215
|
+
const after = read.value;
|
|
216
|
+
const stale = [
|
|
217
|
+
...(name !== undefined && after.DisplayName !== name ? ['name'] : []),
|
|
218
|
+
...(destination !== undefined && after.ParentFolderId !== destination ? ['location'] : []),
|
|
219
|
+
];
|
|
220
|
+
return minifiedResult({
|
|
221
|
+
updated: stale.length === 0,
|
|
222
|
+
...shown(after),
|
|
223
|
+
...(stale.length ? { warning: `Outlook accepted the write but the ${stale.join(' and ')} did not change.` } : {}),
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
server.registerTool('outlook_delete_folder', {
|
|
227
|
+
description: 'Delete a mail folder together with every message and subfolder in it. This is permanent: the folder does NOT go to Deleted Items, so it cannot be restored from there. To keep the mail, move it out first (outlook_move_message). The confirmation totals the mail in the folder and every subfolder beneath it, and names any inbox rules that file into them (those rules stop working). Outlook\'s own folders are refused. The result is verified by re-reading the folder. A folder holding thousands of messages takes minutes to delete, so the call can come back with status "unknown" while Outlook keeps deleting: check its Total in outlook_list_folders falling, and never resend. ' +
|
|
228
|
+
CONFIRM_FLOW_SENTENCE,
|
|
229
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
230
|
+
_meta: workMeta('change', { aliases: ['remove folder'] }),
|
|
231
|
+
inputSchema: z.object({
|
|
232
|
+
id: z.string().min(1).describe('Folder Id from outlook_list_folders'),
|
|
233
|
+
confirmToken: confirmTokenParam,
|
|
234
|
+
}),
|
|
235
|
+
}, async ({ id, confirmToken }, ctx) => {
|
|
236
|
+
refuseWellKnown(id, 'deleted');
|
|
237
|
+
const current = await readFolder(client, id);
|
|
238
|
+
const label = current.DisplayName ?? id;
|
|
239
|
+
const tree = await subtree(client, { ...current, Id: current.Id ?? id });
|
|
240
|
+
const rules = await rulesUsing(client, tree.ids);
|
|
241
|
+
const ruleNote = rules.length
|
|
242
|
+
? ` These inbox rules file mail into it or its subfolders and will stop working: ${rules.map((r) => `"${r}"`).join(', ')}.`
|
|
243
|
+
: '';
|
|
244
|
+
const gate = await confirmWrite(ctx, {
|
|
245
|
+
tool: 'outlook_delete_folder',
|
|
246
|
+
action: 'mail.folder_delete',
|
|
247
|
+
message: `Review and confirm permanently deleting this folder with its ${contents(tree)} (it does not go to Deleted Items):${ruleNote}`,
|
|
248
|
+
summary: `Permanently delete folder "${label}" with its ${contents(tree)}; it does not go to Deleted Items.${ruleNote}`,
|
|
249
|
+
account: undefined,
|
|
250
|
+
target: id,
|
|
251
|
+
request: { method: 'DELETE', path: folderPath(id) },
|
|
252
|
+
confirmToken,
|
|
253
|
+
preview: { current: shown(current), ...(rules.length ? { rulesUsingIt: rules } : {}) },
|
|
254
|
+
});
|
|
255
|
+
if (gate)
|
|
256
|
+
return gate;
|
|
257
|
+
let write;
|
|
258
|
+
try {
|
|
259
|
+
write = await writeOrUnknown(() => client.write('DELETE', folderPath(id)), 'outlook_list_folders');
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
// Built-in folders such as RSS Feeds are not all reachable by a well-known name, so Outlook is the backstop.
|
|
263
|
+
if (errorStatusOf(e) === 400 && /ErrorDeleteDistinguishedFolder/.test(String(e.message))) {
|
|
264
|
+
throw new McpToolError(`"${label}" is one of Outlook's own folders and cannot be deleted.`, {
|
|
265
|
+
hint: 'Nothing was deleted. Leave it in place, or delete the folders you created inside it instead.',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
throw e;
|
|
269
|
+
}
|
|
270
|
+
if (!write.ok)
|
|
271
|
+
return minifiedResult({ status: 'unknown', Id: id, warning: STILL_DELETING });
|
|
272
|
+
const read = await readBack(() => client.get(folderPath(id)));
|
|
273
|
+
if (!read.ok && errorStatusOf(read.error) === 404)
|
|
274
|
+
return minifiedResult({ deleted: true, Id: id });
|
|
275
|
+
if (!read.ok)
|
|
276
|
+
return minifiedResult({ deleted: true, Id: id, ...unverified('outlook_list_folders') });
|
|
277
|
+
return minifiedResult({ deleted: false, Id: id, warning: 'Outlook accepted the delete but the folder is still there.' });
|
|
278
|
+
});
|
|
279
|
+
}
|
package/dist/tools/mail.js
CHANGED
|
@@ -13,7 +13,7 @@ import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js'
|
|
|
13
13
|
* Well-known folder names the API accepts wherever a folder id is taken.
|
|
14
14
|
* All eight verified present on a live mailbox 2026-09-20.
|
|
15
15
|
*/
|
|
16
|
-
const WELL_KNOWN = [
|
|
16
|
+
export const WELL_KNOWN = [
|
|
17
17
|
'inbox',
|
|
18
18
|
'drafts',
|
|
19
19
|
'sentitems',
|
package/dist/tools/rules.js
CHANGED
|
@@ -56,7 +56,7 @@ function outlookConditions(c) {
|
|
|
56
56
|
* first. That also refuses a folder that does not exist before anyone is asked
|
|
57
57
|
* to confirm a rule that could never work.
|
|
58
58
|
*/
|
|
59
|
-
async function folderId(client, folder) {
|
|
59
|
+
export async function folderId(client, folder) {
|
|
60
60
|
if (folder === undefined)
|
|
61
61
|
return undefined;
|
|
62
62
|
try {
|
|
@@ -114,7 +114,7 @@ function endsBeforeStart(start, end) {
|
|
|
114
114
|
* The re-read after a write that Outlook accepted. If only the re-read fails,
|
|
115
115
|
* the write still happened, so an error here would invite a duplicate.
|
|
116
116
|
*/
|
|
117
|
-
async function readBack(read) {
|
|
117
|
+
export async function readBack(read) {
|
|
118
118
|
try {
|
|
119
119
|
return { ok: true, value: await read() };
|
|
120
120
|
}
|
|
@@ -122,7 +122,7 @@ async function readBack(read) {
|
|
|
122
122
|
return { ok: false, error };
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
const unverified = (where) => ({
|
|
125
|
+
export const unverified = (where) => ({
|
|
126
126
|
verified: false,
|
|
127
127
|
warning: `Outlook accepted this change but it could not be re-read. Check ${where} before retrying.`,
|
|
128
128
|
});
|
package/dist/tools/writes.js
CHANGED
|
@@ -6,11 +6,11 @@ import { mailboxTimeZone } from '../timezone.js';
|
|
|
6
6
|
import { cappedRecipients, MAX_RECIPIENTS } from '../view.js';
|
|
7
7
|
import { EVENT_EXPAND, isMeetingMessage, MEETING_MESSAGE_TYPE, resolvedEvent, } from './_invite.js';
|
|
8
8
|
import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js';
|
|
9
|
-
const recipientList = z
|
|
9
|
+
export const recipientList = z
|
|
10
10
|
.array(z.string().min(3))
|
|
11
11
|
.optional()
|
|
12
12
|
.describe('Email addresses');
|
|
13
|
-
function toRecipients(list) {
|
|
13
|
+
export function toRecipients(list) {
|
|
14
14
|
return (list ?? []).map((Address) => ({ EmailAddress: { Address } }));
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
@@ -242,8 +242,8 @@ export function registerWriteTools(server, client) {
|
|
|
242
242
|
return minifiedResult({ sent: true, subject, recipients: recipients || null });
|
|
243
243
|
});
|
|
244
244
|
server.registerTool('outlook_create_draft', {
|
|
245
|
-
description: 'Create a draft message in the Drafts folder without sending it. Returns the created draft, which can be
|
|
246
|
-
'It does not ask to confirm: a draft reaches nobody until it is sent
|
|
245
|
+
description: 'Create a draft message in the Drafts folder without sending it. Returns the created draft, which can be changed with outlook_update_draft and sent with outlook_send_draft. ' +
|
|
246
|
+
'It does not ask to confirm: a draft reaches nobody until it is sent. Calling it twice leaves two drafts. ' +
|
|
247
247
|
TIMEOUT_SENTENCE,
|
|
248
248
|
// Self-only and additive, like outlook_reply with draftOnly: nothing is
|
|
249
249
|
// overwritten or sent. Not idempotent: a repeat creates a second draft.
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/office-outlook-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/office-outlook-mcp",
|
|
5
5
|
"description": "Outlook / Microsoft 365 MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/office-outlook-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.11.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/office-outlook-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.11.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|