@chrischall/office-outlook-mcp 0.5.1 → 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.
@@ -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.5.1"
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.5.1",
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.5.1",
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
@@ -20542,10 +20542,10 @@ var init_v4 = __esm({
20542
20542
  }
20543
20543
  });
20544
20544
 
20545
- // node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
20545
+ // node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
20546
20546
  var LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, RELATED_TASK_META_KEY, PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, SERVER_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY, SUBSCRIPTION_ID_META_KEY, LOG_LEVEL_META_KEY, JSONRPC_VERSION, JSONValueSchema, JSONObjectSchema, JSONArraySchema, ProgressTokenSchema, CursorSchema, TaskMetadataSchema, RelatedTaskMetadataSchema, RequestMetaSchema, BaseRequestParamsSchema, TaskAugmentedRequestParamsSchema, RequestSchema, NotificationsParamsSchema, NotificationSchema, ResultMetaObjectSchema, ResultSchema, RequestIdSchema, JSONRPCRequestSchema, JSONRPCNotificationSchema, JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema, JSONRPCMessageSchema, JSONRPCResponseSchema, EmptyResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, IconSchema, IconsSchema, BaseMetadataSchema, ImplementationSchema, FormElicitationCapabilitySchema, ElicitationCapabilitySchema, ClientTasksCapabilitySchema, ServerTasksCapabilitySchema, ClientCapabilitiesSchema, InitializeRequestParamsSchema, InitializeRequestSchema, ServerCapabilitiesSchema, InitializeResultSchema, InitializedNotificationSchema, DiscoverRequestSchema, DiscoverResultSchema, PingRequestSchema, ProgressSchema, ProgressNotificationParamsSchema, ProgressNotificationSchema, PaginatedRequestParamsSchema, PaginatedRequestSchema, PaginatedResultSchema, ResourceContentsSchema, TextResourceContentsSchema, Base64Schema, BlobResourceContentsSchema, RoleSchema, AnnotationsSchema, ResourceSchema, ResourceTemplateSchema, ListResourcesRequestSchema, ListResourcesResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, ResourceRequestParamsSchema, ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, ResourceListChangedNotificationSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, UnsubscribeRequestParamsSchema, UnsubscribeRequestSchema, SubscriptionFilterSchema, SubscriptionsListenRequestParamsSchema, SubscriptionsListenRequestSchema, SubscriptionsAcknowledgedNotificationParamsSchema, SubscriptionsAcknowledgedNotificationSchema, SubscriptionsListenResultMetaSchema, SubscriptionsListenResultSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, PromptArgumentSchema, PromptSchema, ListPromptsRequestSchema, ListPromptsResultSchema, GetPromptRequestParamsSchema, GetPromptRequestSchema, TextContentSchema, ImageContentSchema, AudioContentSchema, ToolUseContentSchema, EmbeddedResourceSchema, ResourceLinkSchema, ContentBlockSchema, PromptMessageSchema, GetPromptResultSchema, PromptListChangedNotificationSchema, ToolAnnotationsSchema, ToolExecutionSchema, ToolSchema, ListToolsRequestSchema, ListToolsResultSchema, CallToolResultSchema, CompatibilityCallToolResultSchema, CallToolRequestParamsSchema, CallToolRequestSchema, ToolListChangedNotificationSchema, ListChangedOptionsBaseSchema, LoggingLevelSchema, SetLevelRequestParamsSchema, SetLevelRequestSchema, LoggingMessageNotificationParamsSchema, LoggingMessageNotificationSchema, ModelHintSchema, ModelPreferencesSchema, ToolChoiceSchema, ToolResultContentSchema, SamplingContentSchema, SamplingMessageContentBlockSchema, SamplingMessageSchema, CreateMessageRequestParamsSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema, UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema, LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema, EnumSchemaSchema, PrimitiveSchemaDefinitionSchema, ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema, ElicitRequestParamsSchema, ElicitRequestSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, ElicitResultSchema, ResourceTemplateReferenceSchema, PromptReferenceSchema, CompleteRequestParamsSchema, CompleteRequestSchema, CompleteResultSchema, RootSchema, ListRootsRequestSchema, ListRootsResultSchema, RootsListChangedNotificationSchema, TaskCreationParamsSchema, TaskStatusSchema, TaskSchema, CreateTaskResultSchema, TaskStatusNotificationParamsSchema, TaskStatusNotificationSchema, GetTaskRequestSchema, GetTaskResultSchema, GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, ListTasksRequestSchema, ListTasksResultSchema, CancelTaskRequestSchema, CancelTaskResultSchema, ClientRequestSchema, ClientNotificationSchema, ClientResultSchema, ServerRequestSchema, ServerNotificationSchema, ServerResultSchema, SafeUrlSchema, OAuthProtectedResourceMetadataSchema, OAuthMetadataSchema, OpenIdProviderMetadataSchema, OpenIdProviderDiscoveryMetadataSchema, OAuthTokensSchema, IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OptionalSafeUrlSchema, OAuthClientMetadataSchema, OAuthClientInformationSchema, OAuthClientInformationFullSchema, OAuthClientRegistrationErrorSchema, OAuthTokenRevocationRequestSchema;
20547
20547
  var init_auth_BNDyLTqp = __esm({
20548
- "node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs"() {
20548
+ "node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs"() {
20549
20549
  init_v4();
20550
20550
  LATEST_PROTOCOL_VERSION = "2025-11-25";
20551
20551
  SUPPORTED_PROTOCOL_VERSIONS = [
@@ -21489,9 +21489,9 @@ var init_auth_BNDyLTqp = __esm({
21489
21489
  }
21490
21490
  });
21491
21491
 
21492
- // node_modules/@modelcontextprotocol/core/dist/internal.mjs
21492
+ // node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/internal.mjs
21493
21493
  var init_internal = __esm({
21494
- "node_modules/@modelcontextprotocol/core/dist/internal.mjs"() {
21494
+ "node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/internal.mjs"() {
21495
21495
  init_auth_BNDyLTqp();
21496
21496
  }
21497
21497
  });
@@ -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.5.1";
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: limit ?? 25,
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 rows = Array.isArray(data?.value) ? data.value : [];
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
- const next = data?.["@odata.nextLink"];
49327
- if (next) out.nextLink = next;
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
  );
@@ -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
+ }
@@ -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: limit ?? 25,
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 rows = Array.isArray(data?.value) ? data.value : [];
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
- const next = data?.['@odata.nextLink'];
338
- if (next)
339
- out.nextLink = next;
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
@@ -10,4 +10,4 @@
10
10
  * line below and nowhere else — not even in prose.
11
11
  */
12
12
  export const PACKAGE_NAME = 'office-outlook-mcp';
13
- export const VERSION = '0.5.1'; // x-release-please-version
13
+ export const VERSION = '0.6.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.5.1",
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.5.1",
9
+ "version": "0.6.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.5.1",
14
+ "version": "0.6.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },