@chrischall/office-outlook-mcp 0.5.2 → 0.6.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 +12 -0
- package/dist/bundle.js +125 -11
- package/dist/tools/_paging.js +102 -0
- package/dist/tools/mail.js +72 -12
- 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.6.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.6.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.6.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
|
@@ -202,6 +202,18 @@ each asks you to confirm first — except a reply saved with `draftOnly: true`
|
|
|
202
202
|
(it stays in Drafts until you send it) and an invite response with
|
|
203
203
|
`sendResponse: false` (only your calendar changes; the organizer is not told).
|
|
204
204
|
|
|
205
|
+
## Paging from a time
|
|
206
|
+
|
|
207
|
+
`outlook_get_unread` and `outlook_list_messages` also page **oldest first**
|
|
208
|
+
from a point in time: pass `from` as an ISO 8601 UTC time
|
|
209
|
+
(`2026-10-01T00:00:00Z`). The result carries `next_from` — pass it back
|
|
210
|
+
unchanged as `from` for the next page — and `has_more`. To backfill, start
|
|
211
|
+
from an old time and keep passing `next_from` until `has_more` is false; then
|
|
212
|
+
keep polling with the last `next_from` to pick up new mail (an empty page
|
|
213
|
+
returns the same position). Messages that share a timestamp are never skipped
|
|
214
|
+
or repeated across pages, and marking mail read between pages loses nothing.
|
|
215
|
+
`from` cannot be combined with `nextLink`, `sinceHours`, `search` or `skip`.
|
|
216
|
+
|
|
205
217
|
## Confirmations
|
|
206
218
|
|
|
207
219
|
Every write asks you to confirm before anything is sent or changed (except a
|
package/dist/bundle.js
CHANGED
|
@@ -37588,7 +37588,7 @@ var init_version = __esm({
|
|
|
37588
37588
|
"src/version.ts"() {
|
|
37589
37589
|
"use strict";
|
|
37590
37590
|
PACKAGE_NAME = "office-outlook-mcp";
|
|
37591
|
-
VERSION = "0.
|
|
37591
|
+
VERSION = "0.6.0";
|
|
37592
37592
|
}
|
|
37593
37593
|
});
|
|
37594
37594
|
|
|
@@ -49028,6 +49028,7 @@ function isMeetingMessage(m) {
|
|
|
49028
49028
|
|
|
49029
49029
|
// src/tools/_paging.ts
|
|
49030
49030
|
init_zod();
|
|
49031
|
+
init_dist2();
|
|
49031
49032
|
var nextLinkParam = external_exports.string().min(1).optional().describe(
|
|
49032
49033
|
"Continue a previous listing: pass its `nextLink` value unchanged to fetch the next page. When set, the paging/filter arguments are taken from the link and ignored here."
|
|
49033
49034
|
);
|
|
@@ -49043,6 +49044,71 @@ function plainCollection(data) {
|
|
|
49043
49044
|
if (next) out.nextLink = next;
|
|
49044
49045
|
return out;
|
|
49045
49046
|
}
|
|
49047
|
+
var fromParam = external_exports.string().min(1).optional().describe(
|
|
49048
|
+
'Page oldest first from this point: an ISO 8601 UTC time (e.g. "2026-10-01T00:00:00Z"; one with no zone is read as UTC), or a previous result\'s `next_from` passed unchanged. The result always carries `next_from` and `has_more`.'
|
|
49049
|
+
);
|
|
49050
|
+
var MAX_TOP = 1e3;
|
|
49051
|
+
var FROM_HINT = "Pass a previous next_from unchanged, or an ISO 8601 time such as 2026-10-01T00:00:00Z (one with no zone is read as UTC).";
|
|
49052
|
+
var ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i;
|
|
49053
|
+
function utcLiteral(ms) {
|
|
49054
|
+
return new Date(ms).toISOString().replace(/\.000Z$/, "Z");
|
|
49055
|
+
}
|
|
49056
|
+
function parseTime(raw) {
|
|
49057
|
+
if (!ISO_DATE_TIME.test(raw)) return void 0;
|
|
49058
|
+
const t = /T[\d:.]+$/.test(raw) ? `${raw}Z` : raw;
|
|
49059
|
+
const ms = Date.parse(t);
|
|
49060
|
+
if (Number.isNaN(ms)) return void 0;
|
|
49061
|
+
if (new Date(ms + offsetMs(t)).toISOString().slice(0, 10) !== t.slice(0, 10)) return void 0;
|
|
49062
|
+
return ms;
|
|
49063
|
+
}
|
|
49064
|
+
function offsetMs(t) {
|
|
49065
|
+
const m = /([+-])(\d{2}):?(\d{2})$/.exec(t);
|
|
49066
|
+
if (!m || /Z$/i.test(t)) return 0;
|
|
49067
|
+
const mins = Number(m[2]) * 60 + Number(m[3]);
|
|
49068
|
+
return (m[1] === "-" ? -1 : 1) * mins * 6e4;
|
|
49069
|
+
}
|
|
49070
|
+
function encodeFrom(pos) {
|
|
49071
|
+
return Buffer.from(JSON.stringify({ t: pos.t, ids: pos.ids }), "utf8").toString("base64url");
|
|
49072
|
+
}
|
|
49073
|
+
function decodeFrom(from) {
|
|
49074
|
+
if (/^\d{4}-/.test(from)) {
|
|
49075
|
+
const ms2 = parseTime(from);
|
|
49076
|
+
if (ms2 === void 0) {
|
|
49077
|
+
throw new McpToolError(`\`from\` is not a valid ISO 8601 UTC date-time: ${from}`, { hint: FROM_HINT });
|
|
49078
|
+
}
|
|
49079
|
+
return { t: utcLiteral(ms2), ids: [] };
|
|
49080
|
+
}
|
|
49081
|
+
let parsed;
|
|
49082
|
+
try {
|
|
49083
|
+
parsed = /^[A-Za-z0-9_-]+$/.test(from) ? JSON.parse(Buffer.from(from, "base64url").toString("utf8")) : void 0;
|
|
49084
|
+
} catch {
|
|
49085
|
+
parsed = void 0;
|
|
49086
|
+
}
|
|
49087
|
+
const p = parsed;
|
|
49088
|
+
const ms = typeof p?.t === "string" ? parseTime(p.t) : void 0;
|
|
49089
|
+
if (ms === void 0 || !Array.isArray(p?.ids) || !p.ids.every((id) => typeof id === "string")) {
|
|
49090
|
+
throw new McpToolError("`from` is neither an ISO 8601 time nor a next_from token.", { hint: FROM_HINT });
|
|
49091
|
+
}
|
|
49092
|
+
return { t: utcLiteral(ms), ids: p.ids };
|
|
49093
|
+
}
|
|
49094
|
+
function fromQuery(pos, limit) {
|
|
49095
|
+
return { bound: `ReceivedDateTime ge ${pos.t}`, top: Math.min(limit + pos.ids.length, MAX_TOP) };
|
|
49096
|
+
}
|
|
49097
|
+
function fromPage(rows, pos, limit, apiHasMore) {
|
|
49098
|
+
const seen = new Set(pos.ids);
|
|
49099
|
+
const fresh = rows.filter((r) => r.Id === void 0 || !seen.has(r.Id));
|
|
49100
|
+
const kept = fresh.slice(0, limit);
|
|
49101
|
+
const next = nextPosition(kept, pos);
|
|
49102
|
+
return { rows: kept, next_from: encodeFrom(next), has_more: fresh.length > limit || apiHasMore };
|
|
49103
|
+
}
|
|
49104
|
+
function nextPosition(rows, pos) {
|
|
49105
|
+
const dated = rows.map((r) => ({ id: r.Id, ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN })).filter((r) => r.id !== void 0 && !Number.isNaN(r.ms));
|
|
49106
|
+
if (dated.length === 0) return pos;
|
|
49107
|
+
const newest = Math.max(...dated.map((r) => r.ms));
|
|
49108
|
+
const tied = dated.filter((r) => r.ms === newest).map((r) => r.id);
|
|
49109
|
+
const carried = newest === Date.parse(pos.t) ? pos.ids.filter((id) => !tied.includes(id)) : [];
|
|
49110
|
+
return { t: utcLiteral(newest), ids: [...carried, ...tied] };
|
|
49111
|
+
}
|
|
49046
49112
|
|
|
49047
49113
|
// src/tools/_untrusted.ts
|
|
49048
49114
|
init_dist2();
|
|
@@ -49152,6 +49218,37 @@ function utf8Boundary(buf, max) {
|
|
|
49152
49218
|
function sinceLiteral(hours) {
|
|
49153
49219
|
return new Date(Date.now() - hours * 36e5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
49154
49220
|
}
|
|
49221
|
+
function fromPosition(from, conflicts) {
|
|
49222
|
+
if (from === void 0) return void 0;
|
|
49223
|
+
for (const [name, value] of Object.entries(conflicts)) {
|
|
49224
|
+
if (value === void 0) continue;
|
|
49225
|
+
throw new McpToolError(`\`from\` and \`${name}\` cannot be combined.`, {
|
|
49226
|
+
hint: name === "nextLink" ? "To continue a `from` listing, pass its `next_from` as `from`; `nextLink` belongs to the newest-first listing." : `\`from\` pages oldest first by time; drop \`${name}\`, or omit \`from\` for the newest-first listing.`
|
|
49227
|
+
});
|
|
49228
|
+
}
|
|
49229
|
+
return decodeFrom(from);
|
|
49230
|
+
}
|
|
49231
|
+
var FROM_DESCRIPTION = "Pass `from` (an ISO 8601 UTC time, or a previous `next_from`) to page OLDEST first from that time instead: the result carries `next_from` (pass it back unchanged as `from` for the next page; on an empty page it is the same position, so it can be polled for new arrivals) and `has_more` (more already waiting), and no nextLink. Backfill: pass an old `from` and keep passing `next_from` until `has_more` is false; then keep polling with the last `next_from` for new mail.";
|
|
49232
|
+
async function listFrom(client2, pos, opts) {
|
|
49233
|
+
const { bound, top } = fromQuery(pos, opts.limit);
|
|
49234
|
+
const data = await client2.get(
|
|
49235
|
+
`/me/mailfolders/${folderSegment(opts.folder ?? "inbox")}/messages`,
|
|
49236
|
+
{
|
|
49237
|
+
query: {
|
|
49238
|
+
$top: top,
|
|
49239
|
+
$select: LIST_SELECT,
|
|
49240
|
+
// The date bound leads: Outlook rejects a $filter that does not start with the $orderby property.
|
|
49241
|
+
$filter: opts.unreadOnly === true ? `${bound} and IsRead eq false` : bound,
|
|
49242
|
+
$orderby: "ReceivedDateTime asc"
|
|
49243
|
+
}
|
|
49244
|
+
}
|
|
49245
|
+
);
|
|
49246
|
+
const listed = Array.isArray(data?.value) ? data.value : [];
|
|
49247
|
+
const page = fromPage(listed, pos, opts.limit, Boolean(data?.["@odata.nextLink"]));
|
|
49248
|
+
const { "@odata.nextLink": _superseded, ...rest } = data ?? {};
|
|
49249
|
+
const body = opts.view === "raw" ? { ...rest, value: page.rows } : projectCollection({ value: page.rows }, opts.view === "full" ? fullMessage : compactMessage, "message");
|
|
49250
|
+
return mailboxUntrusted({ ...body, next_from: page.next_from, has_more: page.has_more });
|
|
49251
|
+
}
|
|
49155
49252
|
function registerMailTools(server, client2) {
|
|
49156
49253
|
server.registerTool(
|
|
49157
49254
|
"outlook_list_folders",
|
|
@@ -49180,7 +49277,7 @@ function registerMailTools(server, client2) {
|
|
|
49180
49277
|
server.registerTool(
|
|
49181
49278
|
"outlook_list_messages",
|
|
49182
49279
|
{
|
|
49183
|
-
description: 'List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included \u2014 call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless. Each message\'s To list (and Cc, in the full view) holds at most 20 addresses; a longer list adds ToCount/ToTruncated (CcCount/CcTruncated) with the full count \u2014 pass view:"raw" for every address. ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
49280
|
+
description: 'List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included \u2014 call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless. Each message\'s To list (and Cc, in the full view) holds at most 20 addresses; a longer list adds ToCount/ToTruncated (CcCount/CcTruncated) with the full count \u2014 pass view:"raw" for every address. ' + FROM_DESCRIPTION + " Cannot combine with `search`, `skip` or `nextLink`. " + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
49184
49281
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
49185
49282
|
inputSchema: external_exports.object({
|
|
49186
49283
|
view: viewParam(VIEWS2),
|
|
@@ -49189,10 +49286,15 @@ function registerMailTools(server, client2) {
|
|
|
49189
49286
|
skip: external_exports.number().int().min(0).optional().describe("Offset for paging"),
|
|
49190
49287
|
unreadOnly: external_exports.boolean().optional().describe("Only unread messages"),
|
|
49191
49288
|
search: external_exports.string().optional().describe("Full-text search across the whole mailbox; cannot combine with unreadOnly on a first page (ignored with nextLink)"),
|
|
49289
|
+
from: fromParam,
|
|
49192
49290
|
nextLink: nextLinkParam
|
|
49193
49291
|
})
|
|
49194
49292
|
},
|
|
49195
|
-
async ({ view, folder, limit, skip, unreadOnly, search, nextLink }) => {
|
|
49293
|
+
async ({ view, folder, limit, skip, unreadOnly, search, from, nextLink }) => {
|
|
49294
|
+
const pos = fromPosition(from, { nextLink, search, skip });
|
|
49295
|
+
if (pos !== void 0) {
|
|
49296
|
+
return listFrom(client2, pos, { view: resolveView(view, VIEWS2), folder, limit: limit ?? 25, unreadOnly });
|
|
49297
|
+
}
|
|
49196
49298
|
if (nextLink === void 0 && search !== void 0 && unreadOnly === true) {
|
|
49197
49299
|
throw new McpToolError("`search` and `unreadOnly` cannot be combined.", {
|
|
49198
49300
|
hint: "Outlook rejects $search together with $filter. Search first, then filter the results."
|
|
@@ -49246,19 +49348,24 @@ function registerMailTools(server, client2) {
|
|
|
49246
49348
|
server.registerTool(
|
|
49247
49349
|
"outlook_get_unread",
|
|
49248
49350
|
{
|
|
49249
|
-
description: 'Triage batch: the unread messages in a folder (default inbox), newest first, each WITH its plain-text body (truncated to `maxBodyChars`) in one call \u2014 no follow-up outlook_get_message needed. Every item has a `kind`: "mail", "meetingRequest", "meetingCancelled" or "meetingResponse" (a reply to an invite you sent). Requests and cancellations also carry `event` (id, time, organizer, your current `responseStatus`) so the invite can be answered by its event id (when the meeting is no longer on your calendar \u2014 already declined, cancelled or deleted \u2014 the item has a `hint` instead and there is nothing to respond to). `to`/`cc` list at most 20 addresses; a longer list adds `toCount`/`ccCount` and `toTruncated`/`ccTruncated`. This does NOT mark anything read. Processing loop: read the batch \u2192 act on each item (reply, respond to the invite, file, flag \u2014 or leave it) \u2192 call outlook_mark_read on the plain mail you handled, so the next call returns only what is still new. An invite you answer with outlook_respond_to_invite needs no mark-read: Outlook moves it to Deleted Items under a new id, so its old id would 404. Follow `nextLink` for more. ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
49351
|
+
description: 'Triage batch: the unread messages in a folder (default inbox), newest first, each WITH its plain-text body (truncated to `maxBodyChars`) in one call \u2014 no follow-up outlook_get_message needed. Every item has a `kind`: "mail", "meetingRequest", "meetingCancelled" or "meetingResponse" (a reply to an invite you sent). Requests and cancellations also carry `event` (id, time, organizer, your current `responseStatus`) so the invite can be answered by its event id (when the meeting is no longer on your calendar \u2014 already declined, cancelled or deleted \u2014 the item has a `hint` instead and there is nothing to respond to). `to`/`cc` list at most 20 addresses; a longer list adds `toCount`/`ccCount` and `toTruncated`/`ccTruncated`. This does NOT mark anything read. Processing loop: read the batch \u2192 act on each item (reply, respond to the invite, file, flag \u2014 or leave it) \u2192 call outlook_mark_read on the plain mail you handled, so the next call returns only what is still new. An invite you answer with outlook_respond_to_invite needs no mark-read: Outlook moves it to Deleted Items under a new id, so its old id would 404. Follow `nextLink` for more. ' + FROM_DESCRIPTION + " Cannot combine with `sinceHours` or `nextLink`. " + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
49250
49352
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
49251
49353
|
inputSchema: external_exports.object({
|
|
49252
49354
|
folder: external_exports.string().min(1).optional().describe('Folder id or well-known name (default "inbox")'),
|
|
49253
49355
|
limit: external_exports.number().int().min(1).max(50).optional().describe("Max messages (default 25, max 50)"),
|
|
49254
|
-
sinceHours: external_exports.number().positive().max(24 * 365).optional().describe("Only mail received in the last N hours"),
|
|
49356
|
+
sinceHours: external_exports.number().positive().max(24 * 365).optional().describe("Only mail received in the last N hours (not with `from`)"),
|
|
49255
49357
|
includeBody: external_exports.boolean().optional().describe("Fetch each plain-text body (default true). Invite events are resolved either way."),
|
|
49256
49358
|
maxBodyChars: external_exports.number().int().min(100).max(1e5).optional().describe("Truncate each body to this many characters (default 4000); `bodyTruncated` marks a cut"),
|
|
49359
|
+
from: fromParam,
|
|
49257
49360
|
nextLink: nextLinkParam
|
|
49258
49361
|
})
|
|
49259
49362
|
},
|
|
49260
|
-
async ({ folder, limit, sinceHours, includeBody, maxBodyChars, nextLink }) => {
|
|
49363
|
+
async ({ folder, limit, sinceHours, includeBody, maxBodyChars, from, nextLink }) => {
|
|
49364
|
+
const pos = fromPosition(from, { nextLink, sinceHours });
|
|
49365
|
+
const max = limit ?? 25;
|
|
49366
|
+
const keyset = pos !== void 0 ? fromQuery(pos, max) : void 0;
|
|
49261
49367
|
const filter = [
|
|
49368
|
+
keyset?.bound,
|
|
49262
49369
|
sinceHours !== void 0 ? `ReceivedDateTime ge ${sinceLiteral(sinceHours)}` : void 0,
|
|
49263
49370
|
"IsRead eq false"
|
|
49264
49371
|
].filter((c) => c !== void 0).join(" and ");
|
|
@@ -49268,14 +49375,16 @@ function registerMailTools(server, client2) {
|
|
|
49268
49375
|
`/me/mailfolders/${folderSegment(folder ?? "inbox")}/messages`,
|
|
49269
49376
|
{
|
|
49270
49377
|
query: {
|
|
49271
|
-
$top:
|
|
49378
|
+
$top: keyset?.top ?? max,
|
|
49272
49379
|
$select: UNREAD_SELECT,
|
|
49273
49380
|
$filter: filter,
|
|
49274
|
-
$orderby: "ReceivedDateTime desc"
|
|
49381
|
+
$orderby: keyset ? "ReceivedDateTime asc" : "ReceivedDateTime desc"
|
|
49275
49382
|
}
|
|
49276
49383
|
}
|
|
49277
49384
|
);
|
|
49278
|
-
const
|
|
49385
|
+
const listed = Array.isArray(data?.value) ? data.value : [];
|
|
49386
|
+
const page = pos !== void 0 ? fromPage(listed, pos, max, Boolean(data?.["@odata.nextLink"])) : void 0;
|
|
49387
|
+
const rows = page?.rows ?? listed;
|
|
49279
49388
|
const wantBody = includeBody !== false;
|
|
49280
49389
|
const cap = maxBodyChars ?? 4e3;
|
|
49281
49390
|
const items = await mapWithConcurrency(rows, BODY_CONCURRENCY, async (m) => {
|
|
@@ -49323,8 +49432,13 @@ function registerMailTools(server, client2) {
|
|
|
49323
49432
|
return item;
|
|
49324
49433
|
});
|
|
49325
49434
|
const out = { count: items.length, items };
|
|
49326
|
-
|
|
49327
|
-
|
|
49435
|
+
if (page !== void 0) {
|
|
49436
|
+
out.next_from = page.next_from;
|
|
49437
|
+
out.has_more = page.has_more;
|
|
49438
|
+
} else {
|
|
49439
|
+
const next = data?.["@odata.nextLink"];
|
|
49440
|
+
if (next) out.nextLink = next;
|
|
49441
|
+
}
|
|
49328
49442
|
return mailboxUntrusted(out);
|
|
49329
49443
|
}
|
|
49330
49444
|
);
|
package/dist/tools/_paging.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
3
|
import { stripOData } from '../view.js';
|
|
3
4
|
/**
|
|
4
5
|
* Paging for every list tool.
|
|
@@ -37,3 +38,104 @@ export function plainCollection(data) {
|
|
|
37
38
|
out.nextLink = next;
|
|
38
39
|
return out;
|
|
39
40
|
}
|
|
41
|
+
export const fromParam = z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('Page oldest first from this point: an ISO 8601 UTC time (e.g. "2026-10-01T00:00:00Z"; one with no zone is read as UTC), or a previous result\'s `next_from` passed unchanged. The result always carries `next_from` and `has_more`.');
|
|
46
|
+
/** Outlook's per-request `$top` ceiling. */
|
|
47
|
+
const MAX_TOP = 1000;
|
|
48
|
+
const FROM_HINT = 'Pass a previous next_from unchanged, or an ISO 8601 time such as 2026-10-01T00:00:00Z (one with no zone is read as UTC).';
|
|
49
|
+
/** An ISO 8601 date, or date-time with or without a zone. */
|
|
50
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i;
|
|
51
|
+
/**
|
|
52
|
+
* The OData literal for `t` (unquoted), and the canonical form kept in a
|
|
53
|
+
* token. Milliseconds are dropped only when zero — truncating a real fraction
|
|
54
|
+
* would widen `ge` to messages older than the position and repeat them.
|
|
55
|
+
*/
|
|
56
|
+
function utcLiteral(ms) {
|
|
57
|
+
return new Date(ms).toISOString().replace(/\.000Z$/, 'Z');
|
|
58
|
+
}
|
|
59
|
+
function parseTime(raw) {
|
|
60
|
+
if (!ISO_DATE_TIME.test(raw))
|
|
61
|
+
return undefined;
|
|
62
|
+
// A date-time with no zone would parse as LOCAL time; `from` is UTC, so it is pinned there.
|
|
63
|
+
// (A bare date already parses as UTC.)
|
|
64
|
+
const t = /T[\d:.]+$/.test(raw) ? `${raw}Z` : raw;
|
|
65
|
+
const ms = Date.parse(t);
|
|
66
|
+
if (Number.isNaN(ms))
|
|
67
|
+
return undefined;
|
|
68
|
+
// Date.parse rolls 2026-02-30 over to March; a round trip of the date part catches it.
|
|
69
|
+
if (new Date(ms + offsetMs(t)).toISOString().slice(0, 10) !== t.slice(0, 10))
|
|
70
|
+
return undefined;
|
|
71
|
+
return ms;
|
|
72
|
+
}
|
|
73
|
+
/** The zone offset written in `t`, so the calendar date can be checked as written. */
|
|
74
|
+
function offsetMs(t) {
|
|
75
|
+
const m = /([+-])(\d{2}):?(\d{2})$/.exec(t);
|
|
76
|
+
if (!m || /Z$/i.test(t))
|
|
77
|
+
return 0;
|
|
78
|
+
const mins = Number(m[2]) * 60 + Number(m[3]);
|
|
79
|
+
return (m[1] === '-' ? -1 : 1) * mins * 60_000;
|
|
80
|
+
}
|
|
81
|
+
export function encodeFrom(pos) {
|
|
82
|
+
return Buffer.from(JSON.stringify({ t: pos.t, ids: pos.ids }), 'utf8').toString('base64url');
|
|
83
|
+
}
|
|
84
|
+
/** Read `from`: an ISO time, or a `next_from` token. Anything else is a tool error with a hint. */
|
|
85
|
+
export function decodeFrom(from) {
|
|
86
|
+
if (/^\d{4}-/.test(from)) {
|
|
87
|
+
const ms = parseTime(from);
|
|
88
|
+
if (ms === undefined) {
|
|
89
|
+
throw new McpToolError(`\`from\` is not a valid ISO 8601 UTC date-time: ${from}`, { hint: FROM_HINT });
|
|
90
|
+
}
|
|
91
|
+
return { t: utcLiteral(ms), ids: [] };
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = /^[A-Za-z0-9_-]+$/.test(from) ? JSON.parse(Buffer.from(from, 'base64url').toString('utf8')) : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
parsed = undefined;
|
|
99
|
+
}
|
|
100
|
+
const p = parsed;
|
|
101
|
+
const ms = typeof p?.t === 'string' ? parseTime(p.t) : undefined;
|
|
102
|
+
if (ms === undefined ||
|
|
103
|
+
!Array.isArray(p?.ids) ||
|
|
104
|
+
!p.ids.every((id) => typeof id === 'string')) {
|
|
105
|
+
throw new McpToolError('`from` is neither an ISO 8601 time nor a next_from token.', { hint: FROM_HINT });
|
|
106
|
+
}
|
|
107
|
+
return { t: utcLiteral(ms), ids: p.ids };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The `$filter` date bound and `$top` for a page from `pos`. The bound is
|
|
111
|
+
* inclusive (`ge`), so the tied ids come back again: ask for that many extra
|
|
112
|
+
* rows so dropping them still leaves a full page.
|
|
113
|
+
*/
|
|
114
|
+
export function fromQuery(pos, limit) {
|
|
115
|
+
return { bound: `ReceivedDateTime ge ${pos.t}`, top: Math.min(limit + pos.ids.length, MAX_TOP) };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Cut an ascending page fetched with `fromQuery` down to what the caller
|
|
119
|
+
* sees: drop the rows already returned at `pos.t`, keep the first `limit`,
|
|
120
|
+
* and work out the next position. `has_more` is true when rows were left over
|
|
121
|
+
* or the API has another page.
|
|
122
|
+
*/
|
|
123
|
+
export function fromPage(rows, pos, limit, apiHasMore) {
|
|
124
|
+
const seen = new Set(pos.ids);
|
|
125
|
+
const fresh = rows.filter((r) => r.Id === undefined || !seen.has(r.Id));
|
|
126
|
+
const kept = fresh.slice(0, limit);
|
|
127
|
+
const next = nextPosition(kept, pos);
|
|
128
|
+
return { rows: kept, next_from: encodeFrom(next), has_more: fresh.length > limit || apiHasMore };
|
|
129
|
+
}
|
|
130
|
+
function nextPosition(rows, pos) {
|
|
131
|
+
const dated = rows
|
|
132
|
+
.map((r) => ({ id: r.Id, ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN }))
|
|
133
|
+
.filter((r) => r.id !== undefined && !Number.isNaN(r.ms));
|
|
134
|
+
if (dated.length === 0)
|
|
135
|
+
return pos;
|
|
136
|
+
const newest = Math.max(...dated.map((r) => r.ms));
|
|
137
|
+
const tied = dated.filter((r) => r.ms === newest).map((r) => r.id);
|
|
138
|
+
// The position did not move: earlier ties at this instant stay excluded.
|
|
139
|
+
const carried = newest === Date.parse(pos.t) ? pos.ids.filter((id) => !tied.includes(id)) : [];
|
|
140
|
+
return { t: utcLiteral(newest), ids: [...carried, ...tied] };
|
|
141
|
+
}
|
package/dist/tools/mail.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { imageResult, mapWithConcurrency, minifiedResult, resolveView, viewParam, McpToolError, } from '@chrischall/mcp-utils';
|
|
3
3
|
import { addr, addrs, cappedRecipients, compactFolder, compactMessage, fullMessage, projectCollection, VIEWS, } from '../view.js';
|
|
4
4
|
import { EVENT_EXPAND, INVITE_GONE, MEETING_MESSAGE_TYPE, resolvedEvent } from './_invite.js';
|
|
5
|
-
import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
|
|
5
|
+
import { decodeFrom, fetchPage, fromPage, fromParam, fromQuery, nextLinkParam, plainCollection, } from './_paging.js';
|
|
6
6
|
import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js';
|
|
7
7
|
/**
|
|
8
8
|
* Well-known folder names the API accepts wherever a folder id is taken.
|
|
@@ -146,6 +146,43 @@ function utf8Boundary(buf, max) {
|
|
|
146
146
|
function sinceLiteral(hours) {
|
|
147
147
|
return new Date(Date.now() - hours * 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
148
148
|
}
|
|
149
|
+
/** Read `from` once the arguments it cannot be combined with are ruled out. */
|
|
150
|
+
function fromPosition(from, conflicts) {
|
|
151
|
+
if (from === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
for (const [name, value] of Object.entries(conflicts)) {
|
|
154
|
+
if (value === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
throw new McpToolError(`\`from\` and \`${name}\` cannot be combined.`, {
|
|
157
|
+
hint: name === 'nextLink'
|
|
158
|
+
? 'To continue a `from` listing, pass its `next_from` as `from`; `nextLink` belongs to the newest-first listing.'
|
|
159
|
+
: `\`from\` pages oldest first by time; drop \`${name}\`, or omit \`from\` for the newest-first listing.`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return decodeFrom(from);
|
|
163
|
+
}
|
|
164
|
+
/** How `from` reads, shared by both mail listings. */
|
|
165
|
+
const FROM_DESCRIPTION = 'Pass `from` (an ISO 8601 UTC time, or a previous `next_from`) to page OLDEST first from that time instead: the result carries `next_from` (pass it back unchanged as `from` for the next page; on an empty page it is the same position, so it can be polled for new arrivals) and `has_more` (more already waiting), and no nextLink. Backfill: pass an old `from` and keep passing `next_from` until `has_more` is false; then keep polling with the last `next_from` for new mail.';
|
|
166
|
+
/** `outlook_list_messages` with `from`: one oldest-first keyset page. */
|
|
167
|
+
async function listFrom(client, pos, opts) {
|
|
168
|
+
const { bound, top } = fromQuery(pos, opts.limit);
|
|
169
|
+
const data = await client.get(`/me/mailfolders/${folderSegment(opts.folder ?? 'inbox')}/messages`, {
|
|
170
|
+
query: {
|
|
171
|
+
$top: top,
|
|
172
|
+
$select: LIST_SELECT,
|
|
173
|
+
// The date bound leads: Outlook rejects a $filter that does not start with the $orderby property.
|
|
174
|
+
$filter: opts.unreadOnly === true ? `${bound} and IsRead eq false` : bound,
|
|
175
|
+
$orderby: 'ReceivedDateTime asc',
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const listed = Array.isArray(data?.value) ? data.value : [];
|
|
179
|
+
const page = fromPage(listed, pos, opts.limit, Boolean(data?.['@odata.nextLink']));
|
|
180
|
+
const { '@odata.nextLink': _superseded, ...rest } = data ?? {};
|
|
181
|
+
const body = opts.view === 'raw'
|
|
182
|
+
? { ...rest, value: page.rows }
|
|
183
|
+
: projectCollection({ value: page.rows }, opts.view === 'full' ? fullMessage : compactMessage, 'message');
|
|
184
|
+
return mailboxUntrusted({ ...body, next_from: page.next_from, has_more: page.has_more });
|
|
185
|
+
}
|
|
149
186
|
export function registerMailTools(server, client) {
|
|
150
187
|
server.registerTool('outlook_list_folders', {
|
|
151
188
|
description: 'List mail folders with unread and total counts. Use this to discover folder ids before listing messages; the well-known names (' +
|
|
@@ -170,7 +207,9 @@ export function registerMailTools(server, client) {
|
|
|
170
207
|
return minifiedResult(projectCollection(data, compactFolder, 'mail folder'));
|
|
171
208
|
});
|
|
172
209
|
server.registerTool('outlook_list_messages', {
|
|
173
|
-
description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included — call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless. Each message's To list (and Cc, in the full view) holds at most 20 addresses; a longer list adds ToCount/ToTruncated (CcCount/CcTruncated) with the full count — pass view:\"raw\" for every address." +
|
|
210
|
+
description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included — call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless. Each message's To list (and Cc, in the full view) holds at most 20 addresses; a longer list adds ToCount/ToTruncated (CcCount/CcTruncated) with the full count — pass view:\"raw\" for every address. " +
|
|
211
|
+
FROM_DESCRIPTION +
|
|
212
|
+
' Cannot combine with `search`, `skip` or `nextLink`.' +
|
|
174
213
|
' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
175
214
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
176
215
|
inputSchema: z.object({
|
|
@@ -186,9 +225,14 @@ export function registerMailTools(server, client) {
|
|
|
186
225
|
.string()
|
|
187
226
|
.optional()
|
|
188
227
|
.describe('Full-text search across the whole mailbox; cannot combine with unreadOnly on a first page (ignored with nextLink)'),
|
|
228
|
+
from: fromParam,
|
|
189
229
|
nextLink: nextLinkParam,
|
|
190
230
|
}),
|
|
191
|
-
}, async ({ view, folder, limit, skip, unreadOnly, search, nextLink }) => {
|
|
231
|
+
}, async ({ view, folder, limit, skip, unreadOnly, search, from, nextLink }) => {
|
|
232
|
+
const pos = fromPosition(from, { nextLink, search, skip });
|
|
233
|
+
if (pos !== undefined) {
|
|
234
|
+
return listFrom(client, pos, { view: resolveView(view, VIEWS), folder, limit: limit ?? 25, unreadOnly });
|
|
235
|
+
}
|
|
192
236
|
// With nextLink the filters come from the link and are ignored here, so
|
|
193
237
|
// the conflict only matters for a first-page request.
|
|
194
238
|
if (nextLink === undefined && search !== undefined && unreadOnly === true) {
|
|
@@ -240,7 +284,9 @@ export function registerMailTools(server, client) {
|
|
|
240
284
|
return mailboxUntrusted(v === 'full' ? fullMessage(data) : compactMessage(data));
|
|
241
285
|
});
|
|
242
286
|
server.registerTool('outlook_get_unread', {
|
|
243
|
-
description: 'Triage batch: the unread messages in a folder (default inbox), newest first, each WITH its plain-text body (truncated to `maxBodyChars`) in one call — no follow-up outlook_get_message needed. Every item has a `kind`: "mail", "meetingRequest", "meetingCancelled" or "meetingResponse" (a reply to an invite you sent). Requests and cancellations also carry `event` (id, time, organizer, your current `responseStatus`) so the invite can be answered by its event id (when the meeting is no longer on your calendar — already declined, cancelled or deleted — the item has a `hint` instead and there is nothing to respond to). `to`/`cc` list at most 20 addresses; a longer list adds `toCount`/`ccCount` and `toTruncated`/`ccTruncated`. This does NOT mark anything read. Processing loop: read the batch → act on each item (reply, respond to the invite, file, flag — or leave it) → call outlook_mark_read on the plain mail you handled, so the next call returns only what is still new. An invite you answer with outlook_respond_to_invite needs no mark-read: Outlook moves it to Deleted Items under a new id, so its old id would 404. Follow `nextLink` for more.' +
|
|
287
|
+
description: 'Triage batch: the unread messages in a folder (default inbox), newest first, each WITH its plain-text body (truncated to `maxBodyChars`) in one call — no follow-up outlook_get_message needed. Every item has a `kind`: "mail", "meetingRequest", "meetingCancelled" or "meetingResponse" (a reply to an invite you sent). Requests and cancellations also carry `event` (id, time, organizer, your current `responseStatus`) so the invite can be answered by its event id (when the meeting is no longer on your calendar — already declined, cancelled or deleted — the item has a `hint` instead and there is nothing to respond to). `to`/`cc` list at most 20 addresses; a longer list adds `toCount`/`ccCount` and `toTruncated`/`ccTruncated`. This does NOT mark anything read. Processing loop: read the batch → act on each item (reply, respond to the invite, file, flag — or leave it) → call outlook_mark_read on the plain mail you handled, so the next call returns only what is still new. An invite you answer with outlook_respond_to_invite needs no mark-read: Outlook moves it to Deleted Items under a new id, so its old id would 404. Follow `nextLink` for more. ' +
|
|
288
|
+
FROM_DESCRIPTION +
|
|
289
|
+
' Cannot combine with `sinceHours` or `nextLink`.' +
|
|
244
290
|
' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
245
291
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
246
292
|
inputSchema: z.object({
|
|
@@ -251,7 +297,7 @@ export function registerMailTools(server, client) {
|
|
|
251
297
|
.positive()
|
|
252
298
|
.max(24 * 365)
|
|
253
299
|
.optional()
|
|
254
|
-
.describe('Only mail received in the last N hours'),
|
|
300
|
+
.describe('Only mail received in the last N hours (not with `from`)'),
|
|
255
301
|
includeBody: z
|
|
256
302
|
.boolean()
|
|
257
303
|
.optional()
|
|
@@ -263,12 +309,17 @@ export function registerMailTools(server, client) {
|
|
|
263
309
|
.max(100_000)
|
|
264
310
|
.optional()
|
|
265
311
|
.describe('Truncate each body to this many characters (default 4000); `bodyTruncated` marks a cut'),
|
|
312
|
+
from: fromParam,
|
|
266
313
|
nextLink: nextLinkParam,
|
|
267
314
|
}),
|
|
268
|
-
}, async ({ folder, limit, sinceHours, includeBody, maxBodyChars, nextLink }) => {
|
|
315
|
+
}, async ({ folder, limit, sinceHours, includeBody, maxBodyChars, from, nextLink }) => {
|
|
316
|
+
const pos = fromPosition(from, { nextLink, sinceHours });
|
|
317
|
+
const max = limit ?? 25;
|
|
318
|
+
const keyset = pos !== undefined ? fromQuery(pos, max) : undefined;
|
|
269
319
|
// Outlook rejects a $filter that does not lead with the $orderby
|
|
270
320
|
// property ("InefficientFilter"), so the date bound goes first.
|
|
271
321
|
const filter = [
|
|
322
|
+
keyset?.bound,
|
|
272
323
|
sinceHours !== undefined ? `ReceivedDateTime ge ${sinceLiteral(sinceHours)}` : undefined,
|
|
273
324
|
'IsRead eq false',
|
|
274
325
|
]
|
|
@@ -276,13 +327,15 @@ export function registerMailTools(server, client) {
|
|
|
276
327
|
.join(' and ');
|
|
277
328
|
const data = await fetchPage(client, nextLink, `/me/mailfolders/${folderSegment(folder ?? 'inbox')}/messages`, {
|
|
278
329
|
query: {
|
|
279
|
-
$top:
|
|
330
|
+
$top: keyset?.top ?? max,
|
|
280
331
|
$select: UNREAD_SELECT,
|
|
281
332
|
$filter: filter,
|
|
282
|
-
$orderby: 'ReceivedDateTime desc',
|
|
333
|
+
$orderby: keyset ? 'ReceivedDateTime asc' : 'ReceivedDateTime desc',
|
|
283
334
|
},
|
|
284
335
|
});
|
|
285
|
-
const
|
|
336
|
+
const listed = Array.isArray(data?.value) ? data.value : [];
|
|
337
|
+
const page = pos !== undefined ? fromPage(listed, pos, max, Boolean(data?.['@odata.nextLink'])) : undefined;
|
|
338
|
+
const rows = page?.rows ?? listed;
|
|
286
339
|
const wantBody = includeBody !== false;
|
|
287
340
|
const cap = maxBodyChars ?? 4000;
|
|
288
341
|
const items = await mapWithConcurrency(rows, BODY_CONCURRENCY, async (m) => {
|
|
@@ -334,9 +387,16 @@ export function registerMailTools(server, client) {
|
|
|
334
387
|
return item;
|
|
335
388
|
});
|
|
336
389
|
const out = { count: items.length, items };
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
out.
|
|
390
|
+
if (page !== undefined) {
|
|
391
|
+
// The token supersedes the API link, which would resume newest-first.
|
|
392
|
+
out.next_from = page.next_from;
|
|
393
|
+
out.has_more = page.has_more;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const next = data?.['@odata.nextLink'];
|
|
397
|
+
if (next)
|
|
398
|
+
out.nextLink = next;
|
|
399
|
+
}
|
|
340
400
|
return mailboxUntrusted(out);
|
|
341
401
|
});
|
|
342
402
|
server.registerTool('outlook_list_attachments', {
|
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.6.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.6.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/office-outlook-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|