@chrischall/office-outlook-mcp 0.8.2 → 0.8.3

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.8.2"
10
+ "version": "0.8.3"
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.8.2",
18
+ "version": "0.8.3",
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.8.2",
4
+ "version": "0.8.3",
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/dist/bundle.js CHANGED
@@ -39086,7 +39086,7 @@ var init_version = __esm({
39086
39086
  "src/version.ts"() {
39087
39087
  "use strict";
39088
39088
  PACKAGE_NAME = "office-outlook-mcp";
39089
- VERSION = "0.8.2";
39089
+ VERSION = "0.8.3";
39090
39090
  }
39091
39091
  });
39092
39092
 
@@ -43398,7 +43398,7 @@ var require_websocket = __commonJS({
43398
43398
  var http = __require("http");
43399
43399
  var net = __require("net");
43400
43400
  var tls = __require("tls");
43401
- var { randomBytes: randomBytes8, createHash: createHash5 } = __require("crypto");
43401
+ var { randomBytes: randomBytes8, createHash: createHash6 } = __require("crypto");
43402
43402
  var { Duplex, Readable } = __require("stream");
43403
43403
  var { URL: URL2 } = __require("url");
43404
43404
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -44079,7 +44079,7 @@ var require_websocket = __commonJS({
44079
44079
  abortHandshake(websocket, socket, "Invalid Upgrade header");
44080
44080
  return;
44081
44081
  }
44082
- const digest = createHash5("sha1").update(key + GUID).digest("base64");
44082
+ const digest = createHash6("sha1").update(key + GUID).digest("base64");
44083
44083
  if (res.headers["sec-websocket-accept"] !== digest) {
44084
44084
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
44085
44085
  return;
@@ -44448,7 +44448,7 @@ var require_websocket_server = __commonJS({
44448
44448
  var EventEmitter = __require("events");
44449
44449
  var http = __require("http");
44450
44450
  var { Duplex } = __require("stream");
44451
- var { createHash: createHash5 } = __require("crypto");
44451
+ var { createHash: createHash6 } = __require("crypto");
44452
44452
  var extension2 = require_extension();
44453
44453
  var PerMessageDeflate2 = require_permessage_deflate();
44454
44454
  var subprotocol2 = require_subprotocol();
@@ -44755,7 +44755,7 @@ var require_websocket_server = __commonJS({
44755
44755
  );
44756
44756
  }
44757
44757
  if (this._state > RUNNING) return abortHandshake(socket, 503);
44758
- const digest = createHash5("sha1").update(key + GUID).digest("base64");
44758
+ const digest = createHash6("sha1").update(key + GUID).digest("base64");
44759
44759
  const headers = [
44760
44760
  "HTTP/1.1 101 Switching Protocols",
44761
44761
  "Upgrade: websocket",
@@ -50603,6 +50603,7 @@ function isMeetingMessage(m) {
50603
50603
  // src/tools/_paging.ts
50604
50604
  init_zod();
50605
50605
  init_dist2();
50606
+ import { createHash as createHash4 } from "node:crypto";
50606
50607
  var nextLinkParam = external_exports.string().min(1).optional().describe(
50607
50608
  "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."
50608
50609
  );
@@ -50618,6 +50619,10 @@ function plainCollection(data) {
50618
50619
  if (next) out.nextLink = next;
50619
50620
  return out;
50620
50621
  }
50622
+ var MAX_KEY_LENGTH = 16;
50623
+ function idKey(id) {
50624
+ return id.length <= MAX_KEY_LENGTH ? id : createHash4("sha256").update(id).digest("base64url").slice(0, 12);
50625
+ }
50621
50626
  var fromParam = external_exports.string().min(1).optional().describe(
50622
50627
  '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`.'
50623
50628
  );
@@ -50663,24 +50668,24 @@ function decodeFrom(from) {
50663
50668
  if (ms === void 0 || !Array.isArray(p?.ids) || !p.ids.every((id) => typeof id === "string")) {
50664
50669
  throw new McpToolError("`from` is neither an ISO 8601 time nor a next_from token.", { hint: FROM_HINT });
50665
50670
  }
50666
- return { t: utcLiteral(ms), ids: p.ids };
50671
+ return { t: utcLiteral(ms), ids: [...new Set(p.ids.map(idKey))] };
50667
50672
  }
50668
50673
  function fromQuery(pos, limit) {
50669
50674
  return { bound: `ReceivedDateTime ge ${pos.t}`, top: Math.min(limit + pos.ids.length, MAX_TOP) };
50670
50675
  }
50671
50676
  function fromPage(rows, pos, limit, apiHasMore) {
50672
- const seen = new Set(pos.ids);
50673
- const fresh = rows.filter((r) => r.Id === void 0 || !seen.has(r.Id));
50677
+ const seen = new Set(pos.ids.map(idKey));
50678
+ const fresh = rows.filter((r) => r.Id === void 0 || !seen.has(idKey(r.Id)));
50674
50679
  const kept = fresh.slice(0, limit);
50675
50680
  const next = nextPosition(kept, pos);
50676
50681
  return { rows: kept, next_from: encodeFrom(next), has_more: fresh.length > limit || apiHasMore };
50677
50682
  }
50678
50683
  function nextPosition(rows, pos) {
50679
- 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));
50684
+ const dated = rows.map((r) => ({ id: r.Id === void 0 ? void 0 : idKey(r.Id), ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN })).filter((r) => r.id !== void 0 && !Number.isNaN(r.ms));
50680
50685
  if (dated.length === 0) return pos;
50681
50686
  const newest = Math.max(...dated.map((r) => r.ms));
50682
50687
  const tied = dated.filter((r) => r.ms === newest).map((r) => r.id);
50683
- const carried = newest === Date.parse(pos.t) ? pos.ids.filter((id) => !tied.includes(id)) : [];
50688
+ const carried = newest === Date.parse(pos.t) ? pos.ids.map(idKey).filter((id) => !tied.includes(id)) : [];
50684
50689
  return { t: utcLiteral(newest), ids: [...carried, ...tied] };
50685
50690
  }
50686
50691
 
@@ -52381,7 +52386,7 @@ function registerHealthcheckTool(server, client2) {
52381
52386
  // src/tools/export.ts
52382
52387
  init_zod();
52383
52388
  init_dist2();
52384
- import { createHash as createHash4 } from "node:crypto";
52389
+ import { createHash as createHash5 } from "node:crypto";
52385
52390
  import { readdirSync as readdirSync2, readFileSync as readFileSync4, rmdirSync, unlinkSync as unlinkSync5 } from "node:fs";
52386
52391
  import { join as join11 } from "node:path";
52387
52392
 
@@ -52391,7 +52396,7 @@ init_errors3();
52391
52396
  import { createHmac as createHmac4 } from "node:crypto";
52392
52397
  var BLOB_DEFAULT_TTL_MS = 60 * 60 * 1e3;
52393
52398
  var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
52394
- var MAX_KEY_LENGTH = 1024;
52399
+ var MAX_KEY_LENGTH2 = 1024;
52395
52400
  var SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
52396
52401
  function configError(reason) {
52397
52402
  return new McpToolError(`MCP_BLOB_BASE_URL is not usable: ${reason}.`, {
@@ -52424,8 +52429,8 @@ function badInput(message) {
52424
52429
  function encodeKey(key) {
52425
52430
  if (typeof key !== "string" || key.length === 0)
52426
52431
  throw badInput("Blob key must be a non-empty string.");
52427
- if (key.length > MAX_KEY_LENGTH)
52428
- throw badInput(`Blob key is longer than ${MAX_KEY_LENGTH} characters.`);
52432
+ if (key.length > MAX_KEY_LENGTH2)
52433
+ throw badInput(`Blob key is longer than ${MAX_KEY_LENGTH2} characters.`);
52429
52434
  const segments = key.split("/");
52430
52435
  for (const seg of segments) {
52431
52436
  if (seg === "" || seg === "." || seg === ".." || !SAFE_SEGMENT.test(seg)) {
@@ -53082,6 +53087,7 @@ var eventArgsSchema = external_exports.object({
53082
53087
  attachments: external_exports.boolean().optional()
53083
53088
  });
53084
53089
  var positionSchema = external_exports.union([
53090
+ external_exports.object({ at: external_exports.object({ t: external_exports.string().min(1), ids: external_exports.array(external_exports.string()) }) }),
53085
53091
  external_exports.object({ from: external_exports.string().min(1) }),
53086
53092
  external_exports.object({ link: external_exports.string().min(1).optional(), offset: external_exports.number().int().min(0).max(MAX_TOP2) })
53087
53093
  ]);
@@ -53292,7 +53298,7 @@ async function linkBatch(pos, limit, first, follow, keep = () => true) {
53292
53298
  return end;
53293
53299
  }
53294
53300
  function shortId(id) {
53295
- return createHash4("sha256").update(id).digest("hex").slice(0, 16);
53301
+ return createHash5("sha256").update(id).digest("hex").slice(0, 16);
53296
53302
  }
53297
53303
  function uniqueName(name, used) {
53298
53304
  let candidate = name;
@@ -53683,11 +53689,11 @@ function registerExportTools(server, client2, opts = {}) {
53683
53689
  const folder = args.search !== void 0 ? "all" : args.folder ?? "inbox";
53684
53690
  const path = folder === "all" ? "/me/messages" : `/me/mailfolders/${encodeURIComponent(folder)}/messages`;
53685
53691
  const perPage = args.attachments === true ? Math.min(pageSize, ATTACHMENT_PAGE_SIZE) : pageSize;
53686
- const pos = resumed?.pos ?? (args.search !== void 0 ? { offset: 0 } : { from: encodeFrom({ t: since ?? EPOCH, ids: [] }) });
53687
- if ("from" in pos) decodeFrom(pos.from);
53692
+ const stored = resumed?.pos;
53693
+ const pos = stored === void 0 ? args.search !== void 0 ? { offset: 0 } : { at: { t: since ?? EPOCH, ids: [] } } : "from" in stored ? { at: decodeFrom(stored.from) } : "at" in stored ? { at: decodeFrom(encodeFrom(stored.at)) } : stored;
53688
53694
  const fetch2 = async (p, remaining) => {
53689
- if ("from" in p) {
53690
- const at = decodeFrom(p.from);
53695
+ if ("at" in p) {
53696
+ const { at } = p;
53691
53697
  const limit = Math.min(remaining, perPage);
53692
53698
  const { bound, top } = fromQuery(at, limit);
53693
53699
  const filter = until ? `${bound} and ReceivedDateTime lt ${until}` : bound;
@@ -53704,7 +53710,7 @@ function registerExportTools(server, client2, opts = {}) {
53704
53710
  posAfter: () => p
53705
53711
  };
53706
53712
  }
53707
- if (page.has_more && page.next_from === p.from) {
53713
+ if (page.has_more && page.next_from === encodeFrom(at)) {
53708
53714
  return {
53709
53715
  rows: page.rows,
53710
53716
  pos: p,
@@ -53715,10 +53721,10 @@ function registerExportTools(server, client2, opts = {}) {
53715
53721
  }
53716
53722
  return {
53717
53723
  rows: page.rows,
53718
- pos: { from: page.next_from },
53724
+ pos: { at: decodeFrom(page.next_from) },
53719
53725
  hasMore: page.has_more,
53720
53726
  // The first n rows are fresh and ascending, so the keyset position after them is computed the same way.
53721
- posAfter: (n) => n >= page.rows.length ? { from: page.next_from } : { from: fromPage(page.rows.slice(0, n), at, n, true).next_from }
53727
+ posAfter: (n) => n >= page.rows.length ? { at: decodeFrom(page.next_from) } : { at: decodeFrom(fromPage(page.rows.slice(0, n), at, n, true).next_from) }
53722
53728
  };
53723
53729
  }
53724
53730
  return linkBatch(
@@ -53806,8 +53812,9 @@ function registerExportTools(server, client2, opts = {}) {
53806
53812
  }
53807
53813
  const prefer = args.time_zone ? `outlook.timezone="${args.time_zone}"` : void 0;
53808
53814
  const path = args.calendar !== void 0 ? `/me/calendars/${encodeURIComponent(args.calendar)}/calendarview` : "/me/calendarview";
53809
- const pos = resumed?.pos ?? { offset: 0 };
53810
- if ("from" in pos) throw new McpToolError("`cursor` is not a valid export cursor for this tool.");
53815
+ const stored = resumed?.pos ?? { offset: 0 };
53816
+ if (!("offset" in stored)) throw new McpToolError("`cursor` is not a valid export cursor for this tool.");
53817
+ const pos = stored;
53811
53818
  const fetch2 = (p, remaining) => linkBatch(
53812
53819
  p,
53813
53820
  remaining,
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { z } from 'zod';
2
3
  import { McpToolError } from '@chrischall/mcp-utils';
3
4
  import { stripOData } from '../view.js';
@@ -38,6 +39,19 @@ export function plainCollection(data) {
38
39
  out.nextLink = next;
39
40
  return out;
40
41
  }
42
+ /** An Id this short is kept as is (a key, or a test fixture); a real Outlook Id is ~150 characters. */
43
+ const MAX_KEY_LENGTH = 16;
44
+ /**
45
+ * The key a position keeps for a message Id: the Id itself when short, else
46
+ * 12 base64url characters (72 bits) of its SHA-256. Keys only tell apart the
47
+ * messages tied at ONE instant, so a collision is not a practical concern,
48
+ * and a burst of messages in one second costs ~15 characters each in the token
49
+ * rather than a whole Id. A token from an earlier release, carrying whole Ids,
50
+ * is re-keyed on read.
51
+ */
52
+ export function idKey(id) {
53
+ return id.length <= MAX_KEY_LENGTH ? id : createHash('sha256').update(id).digest('base64url').slice(0, 12);
54
+ }
41
55
  export const fromParam = z
42
56
  .string()
43
57
  .min(1)
@@ -104,7 +118,7 @@ export function decodeFrom(from) {
104
118
  !p.ids.every((id) => typeof id === 'string')) {
105
119
  throw new McpToolError('`from` is neither an ISO 8601 time nor a next_from token.', { hint: FROM_HINT });
106
120
  }
107
- return { t: utcLiteral(ms), ids: p.ids };
121
+ return { t: utcLiteral(ms), ids: [...new Set(p.ids.map(idKey))] };
108
122
  }
109
123
  /**
110
124
  * The `$filter` date bound and `$top` for a page from `pos`. The bound is
@@ -121,21 +135,21 @@ export function fromQuery(pos, limit) {
121
135
  * or the API has another page.
122
136
  */
123
137
  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));
138
+ const seen = new Set(pos.ids.map(idKey));
139
+ const fresh = rows.filter((r) => r.Id === undefined || !seen.has(idKey(r.Id)));
126
140
  const kept = fresh.slice(0, limit);
127
141
  const next = nextPosition(kept, pos);
128
142
  return { rows: kept, next_from: encodeFrom(next), has_more: fresh.length > limit || apiHasMore };
129
143
  }
130
144
  function nextPosition(rows, pos) {
131
145
  const dated = rows
132
- .map((r) => ({ id: r.Id, ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN }))
146
+ .map((r) => ({ id: r.Id === undefined ? undefined : idKey(r.Id), ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN }))
133
147
  .filter((r) => r.id !== undefined && !Number.isNaN(r.ms));
134
148
  if (dated.length === 0)
135
149
  return pos;
136
150
  const newest = Math.max(...dated.map((r) => r.ms));
137
151
  const tied = dated.filter((r) => r.ms === newest).map((r) => r.id);
138
152
  // 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)) : [];
153
+ const carried = newest === Date.parse(pos.t) ? pos.ids.map(idKey).filter((id) => !tied.includes(id)) : [];
140
154
  return { t: utcLiteral(newest), ids: [...carried, ...tied] };
141
155
  }
@@ -158,8 +158,15 @@ const eventArgsSchema = z.object({
158
158
  format: z.enum(['jsonl', 'markdown']),
159
159
  attachments: z.boolean().optional(),
160
160
  });
161
- /** Where the next part starts: a keyset position, or a nextLink page + rows consumed from it. */
161
+ /**
162
+ * Where the next part starts: a keyset position, or a nextLink page + rows
163
+ * consumed from it. The keyset position is kept as plain `{ t, ids }` (`at`):
164
+ * the cursor is base64'd as a whole, so a nested `next_from` token would be
165
+ * encoded twice. `from` (that nested token) is still read from cursors an
166
+ * earlier release handed out.
167
+ */
162
168
  const positionSchema = z.union([
169
+ z.object({ at: z.object({ t: z.string().min(1), ids: z.array(z.string()) }) }),
163
170
  z.object({ from: z.string().min(1) }),
164
171
  z.object({ link: z.string().min(1).optional(), offset: z.number().int().min(0).max(MAX_TOP) }),
165
172
  ]);
@@ -877,12 +884,20 @@ export function registerExportTools(server, client, opts = {}) {
877
884
  const folder = args.search !== undefined ? 'all' : (args.folder ?? 'inbox');
878
885
  const path = folder === 'all' ? '/me/messages' : `/me/mailfolders/${encodeURIComponent(folder)}/messages`;
879
886
  const perPage = args.attachments === true ? Math.min(pageSize, ATTACHMENT_PAGE_SIZE) : pageSize;
880
- const pos = resumed?.pos ?? (args.search !== undefined ? { offset: 0 } : { from: encodeFrom({ t: since ?? EPOCH, ids: [] }) });
881
- if ('from' in pos)
882
- decodeFrom(pos.from); // validate a cursor's position before any work
887
+ const stored = resumed?.pos;
888
+ // Validate (and normalise) a cursor's keyset position before any work; read an older cursor's nested token.
889
+ const pos = stored === undefined
890
+ ? args.search !== undefined
891
+ ? { offset: 0 }
892
+ : { at: { t: since ?? EPOCH, ids: [] } }
893
+ : 'from' in stored
894
+ ? { at: decodeFrom(stored.from) }
895
+ : 'at' in stored
896
+ ? { at: decodeFrom(encodeFrom(stored.at)) }
897
+ : stored;
883
898
  const fetch = async (p, remaining) => {
884
- if ('from' in p) {
885
- const at = decodeFrom(p.from);
899
+ if ('at' in p) {
900
+ const { at } = p;
886
901
  const limit = Math.min(remaining, perPage);
887
902
  const { bound, top } = fromQuery(at, limit);
888
903
  const filter = until ? `${bound} and ReceivedDateTime lt ${until}` : bound;
@@ -900,7 +915,7 @@ export function registerExportTools(server, client, opts = {}) {
900
915
  posAfter: () => p,
901
916
  };
902
917
  }
903
- if (page.has_more && page.next_from === p.from) {
918
+ if (page.has_more && page.next_from === encodeFrom(at)) {
904
919
  // No row carried a usable ReceivedDateTime, so the position did not
905
920
  // move; asking again would return (and duplicate) the same page forever.
906
921
  return {
@@ -913,10 +928,12 @@ export function registerExportTools(server, client, opts = {}) {
913
928
  }
914
929
  return {
915
930
  rows: page.rows,
916
- pos: { from: page.next_from },
931
+ pos: { at: decodeFrom(page.next_from) },
917
932
  hasMore: page.has_more,
918
933
  // The first n rows are fresh and ascending, so the keyset position after them is computed the same way.
919
- posAfter: (n) => n >= page.rows.length ? { from: page.next_from } : { from: fromPage(page.rows.slice(0, n), at, n, true).next_from },
934
+ posAfter: (n) => n >= page.rows.length
935
+ ? { at: decodeFrom(page.next_from) }
936
+ : { at: decodeFrom(fromPage(page.rows.slice(0, n), at, n, true).next_from) },
920
937
  };
921
938
  }
922
939
  return linkBatch(p, remaining, () => with429Retry(() => client.get(path, {
@@ -1010,9 +1027,10 @@ export function registerExportTools(server, client, opts = {}) {
1010
1027
  }
1011
1028
  const prefer = args.time_zone ? `outlook.timezone="${args.time_zone}"` : undefined;
1012
1029
  const path = args.calendar !== undefined ? `/me/calendars/${encodeURIComponent(args.calendar)}/calendarview` : '/me/calendarview';
1013
- const pos = resumed?.pos ?? { offset: 0 };
1014
- if ('from' in pos)
1030
+ const stored = resumed?.pos ?? { offset: 0 };
1031
+ if (!('offset' in stored))
1015
1032
  throw new McpToolError('`cursor` is not a valid export cursor for this tool.');
1033
+ const pos = stored;
1016
1034
  const fetch = (p, remaining) => linkBatch(p, remaining, () => with429Retry(() => client.get(path, {
1017
1035
  text: true,
1018
1036
  ...(prefer ? { prefer } : {}),
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.8.2'; // x-release-please-version
13
+ export const VERSION = '0.8.3'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
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.8.2",
9
+ "version": "0.8.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.8.2",
14
+ "version": "0.8.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },