@chrischall/office-outlook-mcp 0.8.1 → 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.1"
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.1",
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.1",
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/README.md CHANGED
@@ -54,6 +54,23 @@ npm i -g @fetchproxy/cli
54
54
  The first capture prints a 6-digit pair code to approve in the ContextMint
55
55
  Bridge popup; the grant persists.
56
56
 
57
+ ### Pairing
58
+
59
+ The server declares **one** host to the bridge — `outlook.cloud.microsoft`, or
60
+ `OUTLOOK_WEB_HOST` — and the same scope on every capture, so once approved it
61
+ neither re-pairs nor asks for wider grants. The bridge keeps one relay tab on
62
+ that host open (it reuses yours if you already have Outlook open).
63
+
64
+ Before 0.8.2 it also declared `outlook.office.com`. A signed-in tab there
65
+ redirects to `outlook.cloud.microsoft`, so the bridge opened another background
66
+ tab on every capture. Upgrading changes the declared domain set, so the bridge
67
+ asks to pair **once** more.
68
+
69
+ If you run the bridge in more than one browser or browser profile, each one is a
70
+ different extension identity, and the server trusts only the one it paired
71
+ with. Keep the bridge enabled in the browser you pair with (or re-pair
72
+ deliberately with `FETCHPROXY_TRUST_NEW_EXTENSION=1` after switching browsers).
73
+
57
74
  ### Install in opencode
58
75
 
59
76
  opencode reads MCP servers from `opencode.json` (project) or
@@ -100,7 +117,8 @@ Everything is optional — with nothing set, the server captures from the browse
100
117
  | `OUTLOOK_DISABLE_FETCHPROXY` | `1` to disable browser capture, requiring `OUTLOOK_ACCESS_TOKEN`. |
101
118
  | `OUTLOOK_API_BASE` | Override the REST base URL. Defaults to `https://outlook.office.com/api/v2.0`. |
102
119
  | `OUTLOOK_WS_PORT` | fetchproxy concentrator port. Defaults to `37149`, the fleet-wide shared port. |
103
- | `OUTLOOK_CAPTURE_TIMEOUT` | Seconds to wait for the tab to make a readable request. Bounds the WHOLE capture, both declared hosts included. Defaults to `30`. |
120
+ | `OUTLOOK_WEB_HOST` | Which Outlook Web host your signed-in tab is on: `outlook.cloud.microsoft` (default) or `outlook.office.com` for a tenant still on the legacy app. Only this one host is declared to the bridge — see [Pairing](#pairing). |
121
+ | `OUTLOOK_CAPTURE_TIMEOUT` | Seconds to wait for the tab to make a readable request. Defaults to `30`. |
104
122
  | `OUTLOOK_TOKEN_CACHE` | `false` to stop caching the token between runs. |
105
123
  | `OUTLOOK_TOKEN_FILE` | Path to the cached token. Defaults to `~/.office-outlook-mcp/token.json`. |
106
124
  | `OUTLOOK_OUTPUT_DIR` | Absolute directory `outlook_export_messages` and `outlook_export_events` write into. Defaults to `~/Downloads/office-outlook-mcp`. When set, a per-call `dest` must be inside it. See [Bulk export](#bulk-export). |
@@ -15,8 +15,9 @@
15
15
  * domain), but the token it carries has `aud: https://outlook.office.com`;
16
16
  * - that token authenticates plain server-side requests to
17
17
  * `outlook.office.com/api/v2.0` with no bridge involved;
18
- * - older tenants still serve the app from `outlook.office.com`, so both
19
- * hosts are declared and captured, under ONE overall window.
18
+ * - older tenants still serve the app from `outlook.office.com`; those set
19
+ * `OUTLOOK_WEB_HOST=outlook.office.com`. Only ONE host is ever declared —
20
+ * see `captureHost` for why.
20
21
  *
21
22
  * We do NOT try to mint a token by calling an endpoint ourselves. There is no
22
23
  * such endpoint reachable from the page's origin — the token comes from MSAL's
@@ -24,19 +25,10 @@
24
25
  * cannot drive. Capture is the only path, which is why a failed capture is a
25
26
  * hard error with actionable guidance rather than a fallback.
26
27
  */
27
- import { captureBearerHeader } from '@chrischall/mcp-utils/fetchproxy';
28
- import { readPortEnv, readTtlMsEnv } from '@chrischall/mcp-utils';
28
+ import { captureBearerHeader, createFetchproxyTransport } from '@chrischall/mcp-utils/fetchproxy';
29
+ import { readEnvVar, readPortEnv, readTtlMsEnv } from '@chrischall/mcp-utils';
29
30
  import { PACKAGE_NAME, VERSION } from './version.js';
30
- /**
31
- * `raceCaptures` settles the per-host attempts under ONE overall deadline:
32
- * `Promise.any` alone is not enough, because the bridge serializes the two
33
- * hosts behind its single connection, so the wait was the sum rather than the
34
- * max — measured live at 14.4s for a 5s window and 36s for a 20s one. At the
35
- * 30s default that lands past the 60s request timeout an MCP client uses by
36
- * default. Re-exported (it is mcp-utils', lifted from here) for the test that
37
- * pins that bound.
38
- */
39
- export { raceCaptures, stripBearer } from '@chrischall/mcp-utils/fetchproxy';
31
+ export { stripBearer } from '@chrischall/mcp-utils/fetchproxy';
40
32
  /**
41
33
  * The fetchproxy concentrator port.
42
34
  *
@@ -51,42 +43,49 @@ export { raceCaptures, stripBearer } from '@chrischall/mcp-utils/fetchproxy';
51
43
  * instead of handing `NaN` to the server.
52
44
  */
53
45
  const DEFAULT_WS_PORT = 37_149;
54
- export function getWsPort() {
55
- return readPortEnv('OUTLOOK_WS_PORT', DEFAULT_WS_PORT);
46
+ export function getWsPort(env = process.env) {
47
+ return readPortEnv('OUTLOOK_WS_PORT', DEFAULT_WS_PORT, { env });
56
48
  }
57
- /** New Outlook Web. This is where a current tenant's tab actually lives. */
58
- const CAPTURE_DECL_CLOUD = {
59
- host: 'outlook.cloud.microsoft',
60
- path: '/*',
61
- headerName: 'authorization',
62
- };
63
- /** Legacy host, still serving the app for tenants not yet migrated. */
64
- const CAPTURE_DECL_OFFICE = {
65
- host: 'outlook.office.com',
66
- path: '/*',
67
- headerName: 'authorization',
68
- };
69
49
  /**
70
- * The hosts a token may be captured from — the two Outlook Web origins.
50
+ * The two Outlook Web origins a signed-in tab can be on: the new one first,
51
+ * then the legacy one still serving tenants not yet migrated.
71
52
  *
72
- * Exported so the trust boundary below can be derived from them rather than
73
- * restated.
53
+ * Only one is ever DECLARED to the bridge (`captureHost`). Both stay listed
54
+ * for validating `OUTLOOK_WEB_HOST` and for a hosted registration's egress.
74
55
  */
75
- export const CAPTURE_HOSTS = [CAPTURE_DECL_CLOUD.host, CAPTURE_DECL_OFFICE.host];
56
+ export const OUTLOOK_WEB_HOSTS = ['outlook.cloud.microsoft', 'outlook.office.com'];
76
57
  /**
77
- * The extension's trust boundary: exactly the hosts we capture from.
58
+ * The ONE host to capture from and declare to the bridge: `OUTLOOK_WEB_HOST`,
59
+ * or `outlook.cloud.microsoft` by default.
78
60
  *
79
- * These were the apex domains `cloud.microsoft` and `office.com`, which the
80
- * extension reads as "any subdomain of either" — the whole of Microsoft 365
81
- * granted to read one header off two known hosts. It is also what sent the
82
- * pairing flow to `m365.cloud.microsoft/chat`, a tab nothing here needs.
61
+ * This declared both hosts until 0.8.1, and the bridge opens a background
62
+ * relay tab for every declared domain that has no tab open, after every hello.
63
+ * A signed-in `outlook.office.com` tab redirects to `outlook.cloud.microsoft`,
64
+ * so no tab ever matched the second domain and each capture opened another
65
+ * one. The second capture also ran after the first (the bridge serializes
66
+ * them), so a capture from the legacy host waited out the other host's window
67
+ * first.
83
68
  *
84
- * Narrowing costs nothing: the capture declarations were always these two
85
- * hosts, and every API call the server makes afterwards is a plain server-side
86
- * `fetch` that the bridge never sees. Verified live 2026-09-20 — capture still
87
- * succeeded, and an already-paired extension did not ask to re-pair.
69
+ * Declaring one host fixes both. It is also the smallest scope the bridge can
70
+ * be asked for, and the same on every hello, so it never offers a grant again.
71
+ * Changing the host changes the domain set, which the bridge re-pairs on — a
72
+ * one-time approval.
73
+ *
74
+ * An unrecognised value throws rather than falling back: capturing from the
75
+ * wrong host just times out, with nothing saying why.
88
76
  */
89
- export const TRUST_DOMAINS = [...CAPTURE_HOSTS];
77
+ export function captureHost(env = process.env) {
78
+ const raw = readEnvVar('OUTLOOK_WEB_HOST', { env });
79
+ if (raw === undefined)
80
+ return OUTLOOK_WEB_HOSTS[0];
81
+ const host = raw.trim().toLowerCase();
82
+ const known = OUTLOOK_WEB_HOSTS.find((h) => h === host);
83
+ if (known === undefined) {
84
+ throw new Error(`OUTLOOK_WEB_HOST=${JSON.stringify(raw)} is not an Outlook Web host. Use ` +
85
+ `${OUTLOOK_WEB_HOSTS.join(' or ')} — whichever your signed-in Outlook tab is on — or unset it.`);
86
+ }
87
+ return known;
88
+ }
90
89
  /**
91
90
  * How long to wait for the page to make a request we can read.
92
91
  *
@@ -96,16 +95,19 @@ export const TRUST_DOMAINS = [...CAPTURE_HOSTS];
96
95
  * long enough that ordinary polling or a user glance lands inside it, short
97
96
  * enough that an idle tab does not hang a tool call for minutes.
98
97
  */
99
- const CAPTURE_TIMEOUT_MS = readTtlMsEnv('OUTLOOK_CAPTURE_TIMEOUT', 30_000);
98
+ const DEFAULT_CAPTURE_WINDOW_MS = 30_000;
100
99
  /**
101
- * Capture an Outlook access token from the user's signed-in browser tab.
102
- *
103
- * Both hosts are attempted: whichever the tab is actually on answers first, and
104
- * the other simply never resolves. The bridge serializes them, so the pair is
105
- * held to one deadline by `raceCaptures` rather than to one deadline each.
106
- * Returns the bare token (no `Bearer ` prefix).
100
+ * Build the capture transport through this module's import binding, so the
101
+ * test can stand in for the bridge. mcp-utils' own default calls its internal
102
+ * reference, which a module mock cannot reach.
103
+ */
104
+ const createCaptureTransport = (opts) => createFetchproxyTransport(opts);
105
+ /**
106
+ * Capture an Outlook access token from the user's signed-in browser tab on
107
+ * `captureHost(env)`. Returns the bare token (no `Bearer ` prefix).
107
108
  */
108
- export async function captureTokenViaFetchproxy() {
109
+ export async function captureTokenViaFetchproxy(env = process.env) {
110
+ const host = captureHost(env);
109
111
  // `captureBearerHeader` declares only `capture_request_header` beside the
110
112
  // `fetch` base verb, derives the bridge deadline from the window (window +
111
113
  // 15 s, never under 30 s) so a raised window is not cut off early, starts the
@@ -114,15 +116,21 @@ export async function captureTokenViaFetchproxy() {
114
116
  // header too short to be a token (real ones are ~5 KB of JWT), and closes
115
117
  // the transport whatever happened.
116
118
  return captureBearerHeader({
117
- captures: [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE],
118
- domains: TRUST_DOMAINS,
119
- windowMs: CAPTURE_TIMEOUT_MS,
120
- port: getWsPort(),
119
+ captures: [{ host, path: '/*', headerName: 'authorization' }],
120
+ // Exactly the host captured from: every API call afterwards is a plain
121
+ // server-side `fetch` the bridge never sees. (The apex `cloud.microsoft` /
122
+ // `office.com` once declared here granted the whole of Microsoft 365.)
123
+ domains: [host],
124
+ // Read per capture, not at import, so the env this server was given wins.
125
+ windowMs: readTtlMsEnv('OUTLOOK_CAPTURE_TIMEOUT', DEFAULT_CAPTURE_WINDOW_MS, { env }),
126
+ port: getWsPort(env),
121
127
  serverName: PACKAGE_NAME,
122
128
  version: VERSION,
123
129
  what: 'an Outlook token',
124
- guidance: 'Open a signed-in Outlook tab (outlook.cloud.microsoft or ' +
125
- 'outlook.office.com), leave it in the foreground for a moment so the ' +
126
- 'page makes a request, and retry.',
130
+ guidance: `Open a signed-in Outlook tab on ${host}, leave it in the foreground for a ` +
131
+ 'moment so the page makes a request, and retry. If your Outlook is on the ' +
132
+ `other host (${OUTLOOK_WEB_HOSTS.filter((h) => h !== host).join(', ')}), set ` +
133
+ 'OUTLOOK_WEB_HOST to it.',
134
+ createTransport: createCaptureTransport,
127
135
  });
128
136
  }
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.1";
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",
@@ -49515,29 +49515,46 @@ var init_fetchproxy = __esm({
49515
49515
  // src/auth-fetchproxy.ts
49516
49516
  var auth_fetchproxy_exports = {};
49517
49517
  __export(auth_fetchproxy_exports, {
49518
- CAPTURE_HOSTS: () => CAPTURE_HOSTS,
49519
- TRUST_DOMAINS: () => TRUST_DOMAINS,
49518
+ OUTLOOK_WEB_HOSTS: () => OUTLOOK_WEB_HOSTS,
49519
+ captureHost: () => captureHost,
49520
49520
  captureTokenViaFetchproxy: () => captureTokenViaFetchproxy,
49521
49521
  getWsPort: () => getWsPort,
49522
- raceCaptures: () => raceCaptures,
49523
49522
  stripBearer: () => stripBearer
49524
49523
  });
49525
- function getWsPort() {
49526
- return readPortEnv("OUTLOOK_WS_PORT", DEFAULT_WS_PORT);
49524
+ function getWsPort(env = process.env) {
49525
+ return readPortEnv("OUTLOOK_WS_PORT", DEFAULT_WS_PORT, { env });
49526
+ }
49527
+ function captureHost(env = process.env) {
49528
+ const raw = readEnvVar("OUTLOOK_WEB_HOST", { env });
49529
+ if (raw === void 0) return OUTLOOK_WEB_HOSTS[0];
49530
+ const host = raw.trim().toLowerCase();
49531
+ const known = OUTLOOK_WEB_HOSTS.find((h) => h === host);
49532
+ if (known === void 0) {
49533
+ throw new Error(
49534
+ `OUTLOOK_WEB_HOST=${JSON.stringify(raw)} is not an Outlook Web host. Use ${OUTLOOK_WEB_HOSTS.join(" or ")} \u2014 whichever your signed-in Outlook tab is on \u2014 or unset it.`
49535
+ );
49536
+ }
49537
+ return known;
49527
49538
  }
49528
- async function captureTokenViaFetchproxy() {
49539
+ async function captureTokenViaFetchproxy(env = process.env) {
49540
+ const host = captureHost(env);
49529
49541
  return captureBearerHeader({
49530
- captures: [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE],
49531
- domains: TRUST_DOMAINS,
49532
- windowMs: CAPTURE_TIMEOUT_MS,
49533
- port: getWsPort(),
49542
+ captures: [{ host, path: "/*", headerName: "authorization" }],
49543
+ // Exactly the host captured from: every API call afterwards is a plain
49544
+ // server-side `fetch` the bridge never sees. (The apex `cloud.microsoft` /
49545
+ // `office.com` once declared here granted the whole of Microsoft 365.)
49546
+ domains: [host],
49547
+ // Read per capture, not at import, so the env this server was given wins.
49548
+ windowMs: readTtlMsEnv("OUTLOOK_CAPTURE_TIMEOUT", DEFAULT_CAPTURE_WINDOW_MS, { env }),
49549
+ port: getWsPort(env),
49534
49550
  serverName: PACKAGE_NAME,
49535
49551
  version: VERSION,
49536
49552
  what: "an Outlook token",
49537
- guidance: "Open a signed-in Outlook tab (outlook.cloud.microsoft or outlook.office.com), leave it in the foreground for a moment so the page makes a request, and retry."
49553
+ guidance: `Open a signed-in Outlook tab on ${host}, leave it in the foreground for a moment so the page makes a request, and retry. If your Outlook is on the other host (${OUTLOOK_WEB_HOSTS.filter((h) => h !== host).join(", ")}), set OUTLOOK_WEB_HOST to it.`,
49554
+ createTransport: createCaptureTransport
49538
49555
  });
49539
49556
  }
49540
- var DEFAULT_WS_PORT, CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE, CAPTURE_HOSTS, TRUST_DOMAINS, CAPTURE_TIMEOUT_MS;
49557
+ var DEFAULT_WS_PORT, OUTLOOK_WEB_HOSTS, DEFAULT_CAPTURE_WINDOW_MS, createCaptureTransport;
49541
49558
  var init_auth_fetchproxy = __esm({
49542
49559
  "src/auth-fetchproxy.ts"() {
49543
49560
  "use strict";
@@ -49546,19 +49563,9 @@ var init_auth_fetchproxy = __esm({
49546
49563
  init_version();
49547
49564
  init_fetchproxy();
49548
49565
  DEFAULT_WS_PORT = 37149;
49549
- CAPTURE_DECL_CLOUD = {
49550
- host: "outlook.cloud.microsoft",
49551
- path: "/*",
49552
- headerName: "authorization"
49553
- };
49554
- CAPTURE_DECL_OFFICE = {
49555
- host: "outlook.office.com",
49556
- path: "/*",
49557
- headerName: "authorization"
49558
- };
49559
- CAPTURE_HOSTS = [CAPTURE_DECL_CLOUD.host, CAPTURE_DECL_OFFICE.host];
49560
- TRUST_DOMAINS = [...CAPTURE_HOSTS];
49561
- CAPTURE_TIMEOUT_MS = readTtlMsEnv("OUTLOOK_CAPTURE_TIMEOUT", 3e4);
49566
+ OUTLOOK_WEB_HOSTS = ["outlook.cloud.microsoft", "outlook.office.com"];
49567
+ DEFAULT_CAPTURE_WINDOW_MS = 3e4;
49568
+ createCaptureTransport = (opts) => createFetchproxyTransport(opts);
49562
49569
  }
49563
49570
  });
49564
49571
 
@@ -50596,6 +50603,7 @@ function isMeetingMessage(m) {
50596
50603
  // src/tools/_paging.ts
50597
50604
  init_zod();
50598
50605
  init_dist2();
50606
+ import { createHash as createHash4 } from "node:crypto";
50599
50607
  var nextLinkParam = external_exports.string().min(1).optional().describe(
50600
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."
50601
50609
  );
@@ -50611,6 +50619,10 @@ function plainCollection(data) {
50611
50619
  if (next) out.nextLink = next;
50612
50620
  return out;
50613
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
+ }
50614
50626
  var fromParam = external_exports.string().min(1).optional().describe(
50615
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`.'
50616
50628
  );
@@ -50656,24 +50668,24 @@ function decodeFrom(from) {
50656
50668
  if (ms === void 0 || !Array.isArray(p?.ids) || !p.ids.every((id) => typeof id === "string")) {
50657
50669
  throw new McpToolError("`from` is neither an ISO 8601 time nor a next_from token.", { hint: FROM_HINT });
50658
50670
  }
50659
- return { t: utcLiteral(ms), ids: p.ids };
50671
+ return { t: utcLiteral(ms), ids: [...new Set(p.ids.map(idKey))] };
50660
50672
  }
50661
50673
  function fromQuery(pos, limit) {
50662
50674
  return { bound: `ReceivedDateTime ge ${pos.t}`, top: Math.min(limit + pos.ids.length, MAX_TOP) };
50663
50675
  }
50664
50676
  function fromPage(rows, pos, limit, apiHasMore) {
50665
- const seen = new Set(pos.ids);
50666
- 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)));
50667
50679
  const kept = fresh.slice(0, limit);
50668
50680
  const next = nextPosition(kept, pos);
50669
50681
  return { rows: kept, next_from: encodeFrom(next), has_more: fresh.length > limit || apiHasMore };
50670
50682
  }
50671
50683
  function nextPosition(rows, pos) {
50672
- 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));
50673
50685
  if (dated.length === 0) return pos;
50674
50686
  const newest = Math.max(...dated.map((r) => r.ms));
50675
50687
  const tied = dated.filter((r) => r.ms === newest).map((r) => r.id);
50676
- 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)) : [];
50677
50689
  return { t: utcLiteral(newest), ids: [...carried, ...tied] };
50678
50690
  }
50679
50691
 
@@ -52374,7 +52386,7 @@ function registerHealthcheckTool(server, client2) {
52374
52386
  // src/tools/export.ts
52375
52387
  init_zod();
52376
52388
  init_dist2();
52377
- import { createHash as createHash4 } from "node:crypto";
52389
+ import { createHash as createHash5 } from "node:crypto";
52378
52390
  import { readdirSync as readdirSync2, readFileSync as readFileSync4, rmdirSync, unlinkSync as unlinkSync5 } from "node:fs";
52379
52391
  import { join as join11 } from "node:path";
52380
52392
 
@@ -52384,7 +52396,7 @@ init_errors3();
52384
52396
  import { createHmac as createHmac4 } from "node:crypto";
52385
52397
  var BLOB_DEFAULT_TTL_MS = 60 * 60 * 1e3;
52386
52398
  var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
52387
- var MAX_KEY_LENGTH = 1024;
52399
+ var MAX_KEY_LENGTH2 = 1024;
52388
52400
  var SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
52389
52401
  function configError(reason) {
52390
52402
  return new McpToolError(`MCP_BLOB_BASE_URL is not usable: ${reason}.`, {
@@ -52417,8 +52429,8 @@ function badInput(message) {
52417
52429
  function encodeKey(key) {
52418
52430
  if (typeof key !== "string" || key.length === 0)
52419
52431
  throw badInput("Blob key must be a non-empty string.");
52420
- if (key.length > MAX_KEY_LENGTH)
52421
- 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.`);
52422
52434
  const segments = key.split("/");
52423
52435
  for (const seg of segments) {
52424
52436
  if (seg === "" || seg === "." || seg === ".." || !SAFE_SEGMENT.test(seg)) {
@@ -53075,6 +53087,7 @@ var eventArgsSchema = external_exports.object({
53075
53087
  attachments: external_exports.boolean().optional()
53076
53088
  });
53077
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()) }) }),
53078
53091
  external_exports.object({ from: external_exports.string().min(1) }),
53079
53092
  external_exports.object({ link: external_exports.string().min(1).optional(), offset: external_exports.number().int().min(0).max(MAX_TOP2) })
53080
53093
  ]);
@@ -53285,7 +53298,7 @@ async function linkBatch(pos, limit, first, follow, keep = () => true) {
53285
53298
  return end;
53286
53299
  }
53287
53300
  function shortId(id) {
53288
- return createHash4("sha256").update(id).digest("hex").slice(0, 16);
53301
+ return createHash5("sha256").update(id).digest("hex").slice(0, 16);
53289
53302
  }
53290
53303
  function uniqueName(name, used) {
53291
53304
  let candidate = name;
@@ -53676,11 +53689,11 @@ function registerExportTools(server, client2, opts = {}) {
53676
53689
  const folder = args.search !== void 0 ? "all" : args.folder ?? "inbox";
53677
53690
  const path = folder === "all" ? "/me/messages" : `/me/mailfolders/${encodeURIComponent(folder)}/messages`;
53678
53691
  const perPage = args.attachments === true ? Math.min(pageSize, ATTACHMENT_PAGE_SIZE) : pageSize;
53679
- const pos = resumed?.pos ?? (args.search !== void 0 ? { offset: 0 } : { from: encodeFrom({ t: since ?? EPOCH, ids: [] }) });
53680
- 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;
53681
53694
  const fetch2 = async (p, remaining) => {
53682
- if ("from" in p) {
53683
- const at = decodeFrom(p.from);
53695
+ if ("at" in p) {
53696
+ const { at } = p;
53684
53697
  const limit = Math.min(remaining, perPage);
53685
53698
  const { bound, top } = fromQuery(at, limit);
53686
53699
  const filter = until ? `${bound} and ReceivedDateTime lt ${until}` : bound;
@@ -53697,7 +53710,7 @@ function registerExportTools(server, client2, opts = {}) {
53697
53710
  posAfter: () => p
53698
53711
  };
53699
53712
  }
53700
- if (page.has_more && page.next_from === p.from) {
53713
+ if (page.has_more && page.next_from === encodeFrom(at)) {
53701
53714
  return {
53702
53715
  rows: page.rows,
53703
53716
  pos: p,
@@ -53708,10 +53721,10 @@ function registerExportTools(server, client2, opts = {}) {
53708
53721
  }
53709
53722
  return {
53710
53723
  rows: page.rows,
53711
- pos: { from: page.next_from },
53724
+ pos: { at: decodeFrom(page.next_from) },
53712
53725
  hasMore: page.has_more,
53713
53726
  // The first n rows are fresh and ascending, so the keyset position after them is computed the same way.
53714
- 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) }
53715
53728
  };
53716
53729
  }
53717
53730
  return linkBatch(
@@ -53799,8 +53812,9 @@ function registerExportTools(server, client2, opts = {}) {
53799
53812
  }
53800
53813
  const prefer = args.time_zone ? `outlook.timezone="${args.time_zone}"` : void 0;
53801
53814
  const path = args.calendar !== void 0 ? `/me/calendars/${encodeURIComponent(args.calendar)}/calendarview` : "/me/calendarview";
53802
- const pos = resumed?.pos ?? { offset: 0 };
53803
- 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;
53804
53818
  const fetch2 = (p, remaining) => linkBatch(
53805
53819
  p,
53806
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.1'; // x-release-please-version
13
+ export const VERSION = '0.8.3'; // x-release-please-version
package/mint.yaml CHANGED
@@ -55,6 +55,12 @@ env:
55
55
  fetchproxy concentrator port used for token capture. Defaults to 37149,
56
56
  the fleet-wide shared port. Name this variable in the registration's
57
57
  bridgePortEnv for a bridged deployment.
58
+ - name: OUTLOOK_WEB_HOST
59
+ required: false
60
+ help: >-
61
+ Which Outlook Web host the signed-in tab is on: outlook.cloud.microsoft
62
+ (default) or outlook.office.com for a tenant still on the legacy app.
63
+ Only this one host is declared to the browser bridge.
58
64
  - name: OUTLOOK_CAPTURE_TIMEOUT
59
65
  required: false
60
66
  help: >-
@@ -79,10 +85,10 @@ state:
79
85
  re-captures from the browser — and on a scale-to-zero machine that means
80
86
  re-prompting the user to approve pairing, constantly.
81
87
  egress:
82
- # outlook.office.com is the only host the API client fetches. The two
83
- # Outlook Web hosts the token is captured from are allowed too, so a hosted
84
- # registration is not blocked on either — outlook.cloud.microsoft is where a
85
- # current tenant's tab lives.
88
+ # outlook.office.com is the only host the API client fetches. Both Outlook
89
+ # Web hosts a token can be captured from (OUTLOOK_WEB_HOST picks one) are
90
+ # allowed, so a hosted registration is not blocked on either —
91
+ # outlook.cloud.microsoft is where a current tenant's tab lives.
86
92
  allow:
87
93
  - outlook.cloud.microsoft
88
94
  - outlook.office.com
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.8.1",
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.1",
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.1",
14
+ "version": "0.8.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -41,6 +41,12 @@
41
41
  "isRequired": false,
42
42
  "format": "string"
43
43
  },
44
+ {
45
+ "name": "OUTLOOK_WEB_HOST",
46
+ "description": "Which Outlook Web host your signed-in tab is on: outlook.cloud.microsoft (default) or outlook.office.com for a tenant still on the legacy app. Only this one host is declared to the browser bridge.",
47
+ "isRequired": false,
48
+ "format": "string"
49
+ },
44
50
  {
45
51
  "name": "OUTLOOK_CAPTURE_TIMEOUT",
46
52
  "description": "Seconds to wait for the Outlook tab to make a request we can read a token from. Defaults to 30.",
@@ -13,14 +13,19 @@ token** (~25h lifetime) — every actual request is a plain server-side `curl`.
13
13
 
14
14
  ```sh
15
15
  npm i -g @fetchproxy/cli # provides `fpx`
16
- fpx profile add outlook --domain outlook.cloud.microsoft \
17
- --domain outlook.office.com
16
+ fpx profile add outlook --domain outlook.cloud.microsoft
18
17
  fpx profile declare outlook \
19
18
  --capture-header authorization@outlook.cloud.microsoft \
20
- --capture-header authorization@outlook.office.com \
21
19
  --allow-download
22
20
  ```
23
21
 
22
+ Declare **one** host, the one your signed-in Outlook tab is on. A tenant still
23
+ on the legacy app uses `outlook.office.com` in both lines and exports
24
+ `OUTLOOK_CAPTURE_HOST=outlook.office.com`. Don't declare both: the bridge opens a
25
+ background tab for every declared domain with no tab open, and because a
26
+ signed-in `outlook.office.com` tab redirects to `outlook.cloud.microsoft`, it
27
+ opens another one on every capture.
28
+
24
29
  Declare **all** scopes before the first pairing — widening them later forces the
25
30
  user to re-approve. The first `fpx` call prints a 6-digit pair code to approve in
26
31
  the ContextMint Bridge extension popup; the grant then persists.