@chrischall/office-outlook-mcp 0.8.0 → 0.8.2

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.0"
10
+ "version": "0.8.2"
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.0",
18
+ "version": "0.8.2",
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.0",
4
+ "version": "0.8.2",
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.0";
39089
+ VERSION = "0.8.2";
39090
39090
  }
39091
39091
  });
39092
39092
 
@@ -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
 
@@ -50429,7 +50436,7 @@ function workMeta(intent, extra = {}) {
50429
50436
  return { mint: { domain: "work", intent, ...extra } };
50430
50437
  }
50431
50438
  var BRIEF_MAX = 120;
50432
- var BRIEF_NOTE = '"brief": one line per item, `title \u2014 <ISO date or "no date"> \u2014 <status>`, at most 120 chars each, as `data` in the untrusted-content envelope; a last line `-- more` means another page exists (re-ask without "brief" for its nextLink).';
50439
+ var BRIEF_NOTE = '"brief": one line per item, `title \u2014 <ISO date or "no date"> \u2014 <status>`, at most 120 chars each, as `data` in the untrusted-content envelope; a line starting `-- ` is a note (`-- none` for an empty page; a title starting `-- ` or `\\` gets one leading `\\`); a last line `-- more` means another page exists (re-ask without "brief" for its nextLink).';
50433
50440
  function briefViewParam(rungs, note) {
50434
50441
  const base = viewParam(rungs);
50435
50442
  return external_exports.enum([...rungs, "brief"]).optional().describe(`${base.description ?? ""} ${BRIEF_NOTE} ${note}`.trim());
@@ -50441,15 +50448,23 @@ function flat(s) {
50441
50448
  function briefDate(dt) {
50442
50449
  return flat(dt?.replace(/\.0+(?=Z?$)/, "")) ?? "no date";
50443
50450
  }
50451
+ var TITLE_FLOOR = 24;
50452
+ var cut = (s, n) => s.length > n ? `${s.slice(0, Math.max(n - 1, 0))}\u2026` : s;
50444
50453
  function briefLine(title, date5, status) {
50445
- const tail = ` \u2014 ${date5} \u2014 ${status}`;
50446
- const shown = flat(title) ?? "(no subject)";
50447
- const room = BRIEF_MAX - tail.length;
50448
- return `${shown.length > room ? `${shown.slice(0, Math.max(room - 1, 0))}\u2026` : shown}${tail}`;
50454
+ const raw = flat(title) ?? "(no subject)";
50455
+ const shown = raw.startsWith("-- ") || raw.startsWith("\\") ? `\\${raw}` : raw;
50456
+ const sep2 = ` \u2014 ${date5} \u2014 `;
50457
+ const room = BRIEF_MAX - sep2.length;
50458
+ if (shown.length + status.length <= room) return `${shown}${sep2}${status}`;
50459
+ const titleRoom = Math.max(room - status.length, Math.min(shown.length, TITLE_FLOOR));
50460
+ const t = cut(shown, titleRoom);
50461
+ return cut(`${t}${sep2}${cut(status, room - t.length)}`, BRIEF_MAX);
50449
50462
  }
50450
50463
  var BRIEF_MORE = "-- more";
50464
+ var BRIEF_NONE = "-- none";
50451
50465
  function briefResult(lines, hasMore = false) {
50452
- return mailboxUntrusted(hasMore ? [...lines, BRIEF_MORE] : lines);
50466
+ const items = lines.length > 0 ? lines : [BRIEF_NONE];
50467
+ return mailboxUntrusted(hasMore ? [...items, BRIEF_MORE] : items);
50453
50468
  }
50454
50469
  function spaced(value) {
50455
50470
  return value.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
@@ -50914,7 +50929,7 @@ function registerMailTools(server, client2) {
50914
50929
  _meta: workMeta("whats-new", { canonical: true, aliases: ["email", "mail", "inbox", "unread", "new mail", "work email"] }),
50915
50930
  inputSchema: external_exports.object({
50916
50931
  view: external_exports.enum(["brief"]).optional().describe(
50917
- 'Omit for the triage batch above. "brief": one line per message, `<sender>: <subject> \u2014 <received ISO time or "no date"> \u2014 <unread | meeting request | meeting cancelled | meeting response>`, at most 120 chars each, as `data` in the untrusted-content envelope; fetches no body and no invite; a last line `-- more` means another page exists (re-ask without "brief" for its nextLink / next_from).'
50932
+ 'Omit for the triage batch above. "brief": one line per message, `<sender>: <subject> \u2014 <received ISO time or "no date"> \u2014 <unread | meeting request | meeting cancelled | meeting response>`, at most 120 chars each, as `data` in the untrusted-content envelope; fetches no body and no invite; a line starting `-- ` is a note (`-- none` for an empty page; a title starting `-- ` or `\\` gets one leading `\\`); a last line `-- more` means another page exists (re-ask without "brief" for its nextLink / next_from).'
50918
50933
  ),
50919
50934
  folder: external_exports.string().min(1).optional().describe('Folder id or well-known name (default "inbox")'),
50920
50935
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max messages (default 25, max 50)"),
@@ -20,7 +20,7 @@ export function workMeta(intent, extra = {}) {
20
20
  }
21
21
  /** The longest a brief line may be. */
22
22
  export const BRIEF_MAX = 120;
23
- const BRIEF_NOTE = '"brief": one line per item, `title — <ISO date or "no date"> — <status>`, at most 120 chars each, as `data` in the untrusted-content envelope; a last line `-- more` means another page exists (re-ask without "brief" for its nextLink).';
23
+ const BRIEF_NOTE = '"brief": one line per item, `title — <ISO date or "no date"> — <status>`, at most 120 chars each, as `data` in the untrusted-content envelope; a line starting `-- ` is a note (`-- none` for an empty page; a title starting `-- ` or `\\` gets one leading `\\`); a last line `-- more` means another page exists (re-ask without "brief" for its nextLink).';
24
24
  /**
25
25
  * The `view` parameter of a canonical read: the tool's own rungs plus
26
26
  * `brief`. Absent still means the tool's default.
@@ -41,26 +41,45 @@ function flat(s) {
41
41
  export function briefDate(dt) {
42
42
  return flat(dt?.replace(/\.0+(?=Z?$)/, '')) ?? 'no date';
43
43
  }
44
- /** `title — date — status`, the title cut with an ellipsis to keep the line ≤ 120. */
44
+ /** The shortest a title is cut to before the status gives way too. */
45
+ const TITLE_FLOOR = 24;
46
+ const cut = (s, n) => (s.length > n ? `${s.slice(0, Math.max(n - 1, 0))}…` : s);
47
+ /**
48
+ * `title — date — status`, cut to ≤ 120 chars as a WHOLE, title first: the
49
+ * title shortens until the line fits; once it is at its floor the status is
50
+ * cut too (and, past that, the line itself). A title that starts with `-- `
51
+ * or `\` is escaped with one leading `\`, so a subject or sender name can
52
+ * never read as a note — mcp-host strips that one backslash from every item
53
+ * line after its note check.
54
+ */
45
55
  export function briefLine(title, date, status) {
46
- const tail = ` — ${date} — ${status}`;
47
- const shown = flat(title) ?? '(no subject)';
48
- const room = BRIEF_MAX - tail.length;
49
- return `${shown.length > room ? `${shown.slice(0, Math.max(room - 1, 0))}…` : shown}${tail}`;
56
+ const raw = flat(title) ?? '(no subject)';
57
+ const shown = raw.startsWith('-- ') || raw.startsWith('\\') ? `\\${raw}` : raw;
58
+ const sep = ` — ${date} — `;
59
+ const room = BRIEF_MAX - sep.length;
60
+ if (shown.length + status.length <= room)
61
+ return `${shown}${sep}${status}`;
62
+ const titleRoom = Math.max(room - status.length, Math.min(shown.length, TITLE_FLOOR));
63
+ const t = cut(shown, titleRoom);
64
+ return cut(`${t}${sep}${cut(status, room - t.length)}`, BRIEF_MAX);
50
65
  }
51
66
  /** The note line a brief answer ends with when Outlook holds another page. */
52
67
  export const BRIEF_MORE = '-- more';
68
+ /** The single note line a brief answer with no items carries. */
69
+ export const BRIEF_NONE = '-- none';
53
70
  /**
54
71
  * One line per item, inside the untrusted-data envelope as `data: string[]`
55
72
  * (mcp-host's brief-view contract): sender names, subjects and event text are
56
73
  * written by other people, so a brief line is fenced like every other rung of
57
- * these tools. A line starting with `-- ` is a NOTE, not an item: `-- more`
74
+ * these tools. A line starting with `-- ` is a NOTE, not an item: `-- none`
75
+ * answers an empty page, and `-- more`
58
76
  * says Outlook has another page (it gives no total, so no `N of M`) — the
59
77
  * brief view never silently drops one. Re-ask without `view: "brief"` for the
60
78
  * `nextLink` / `next_from` to follow it.
61
79
  */
62
80
  export function briefResult(lines, hasMore = false) {
63
- return mailboxUntrusted(hasMore ? [...lines, BRIEF_MORE] : lines);
81
+ const items = lines.length > 0 ? lines : [BRIEF_NONE];
82
+ return mailboxUntrusted(hasMore ? [...items, BRIEF_MORE] : items);
64
83
  }
65
84
  /** `NotStarted` → `not started`, `TentativelyAccepted` → `tentatively accepted`. */
66
85
  export function spaced(value) {
@@ -306,7 +306,7 @@ export function registerMailTools(server, client) {
306
306
  view: z
307
307
  .enum(['brief'])
308
308
  .optional()
309
- .describe('Omit for the triage batch above. "brief": one line per message, `<sender>: <subject> — <received ISO time or "no date"> — <unread | meeting request | meeting cancelled | meeting response>`, at most 120 chars each, as `data` in the untrusted-content envelope; fetches no body and no invite; a last line `-- more` means another page exists (re-ask without "brief" for its nextLink / next_from).'),
309
+ .describe('Omit for the triage batch above. "brief": one line per message, `<sender>: <subject> — <received ISO time or "no date"> — <unread | meeting request | meeting cancelled | meeting response>`, at most 120 chars each, as `data` in the untrusted-content envelope; fetches no body and no invite; a line starting `-- ` is a note (`-- none` for an empty page; a title starting `-- ` or `\\` gets one leading `\\`); a last line `-- more` means another page exists (re-ask without "brief" for its nextLink / next_from).'),
310
310
  folder: z.string().min(1).optional().describe('Folder id or well-known name (default "inbox")'),
311
311
  limit: z.number().int().min(1).max(50).optional().describe('Max messages (default 25, max 50)'),
312
312
  sinceHours: z
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.0'; // x-release-please-version
13
+ export const VERSION = '0.8.2'; // 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.0",
3
+ "version": "0.8.2",
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.0",
9
+ "version": "0.8.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.8.0",
14
+ "version": "0.8.2",
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.
@@ -86,8 +91,10 @@ When the office-outlook-mcp server is hosted on mcp-host, its tools join the
86
91
  each answered with `view: "brief"` (one line per item,
87
92
  `title — <ISO date | no date> — <status>`, returned inside the
88
93
  untrusted-content envelope as `data: string[]` because the text is other
89
- people's; a final `-- more` line is a note that Outlook has another page —
90
- re-ask without `brief` for the `nextLink`). `brief` is additive: on
94
+ people's; a line starting `-- ` is a note — `-- none` answers an empty page,
95
+ and a final `-- more` says Outlook has another page, so re-ask without
96
+ `brief` for the `nextLink`; a title starting `-- ` or `\` is escaped with one
97
+ leading `\`). `brief` is additive: on
91
98
  `outlook_list_events` and `outlook_list_tasks` it joins the existing
92
99
  `compact | full | raw` values, and `outlook_list_unread_messages` takes a
93
100
  new optional `view` whose only value is `brief`: