@chrischall/office-outlook-mcp 0.6.1 → 0.6.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.6.1"
10
+ "version": "0.6.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.6.1",
18
+ "version": "0.6.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.6.1",
4
+ "version": "0.6.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",
@@ -24,9 +24,19 @@
24
24
  * cannot drive. Capture is the only path, which is why a failed capture is a
25
25
  * hard error with actionable guidance rather than a fallback.
26
26
  */
27
- import { createBootstrapOpts, createFetchproxyTransport, } from '@chrischall/mcp-utils/fetchproxy';
27
+ import { captureBearerHeader } from '@chrischall/mcp-utils/fetchproxy';
28
28
  import { readPortEnv, readTtlMsEnv } from '@chrischall/mcp-utils';
29
29
  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';
30
40
  /**
31
41
  * The fetchproxy concentrator port.
32
42
  *
@@ -87,81 +97,6 @@ export const TRUST_DOMAINS = [...CAPTURE_HOSTS];
87
97
  * enough that an idle tab does not hang a tool call for minutes.
88
98
  */
89
99
  const CAPTURE_TIMEOUT_MS = readTtlMsEnv('OUTLOOK_CAPTURE_TIMEOUT', 30_000);
90
- /**
91
- * The transport-wide deadline, derived from the capture window rather than left
92
- * at the library default.
93
- *
94
- * `captureRequestHeader`'s own `timeoutMs` is silently capped by
95
- * `fetchTimeoutMs`: a window raised past the deadline fails early at the
96
- * deadline's number, which reads as a timeout nobody configured. Deriving it
97
- * means the two cannot disagree.
98
- */
99
- const BRIDGE_DEADLINE_MS = Math.max(CAPTURE_TIMEOUT_MS + 15_000, 30_000);
100
- /**
101
- * Shortest plausible token. Real ones are ~5 KB of JWT; this only guards
102
- * against an empty or echoed header, not against a wrong-audience token.
103
- */
104
- const MIN_TOKEN_LENGTH = 100;
105
- /**
106
- * Add `fetch` to the derived capability set.
107
- *
108
- * `createBootstrapOpts` infers capabilities from the bootstrap DECLARATIONS and
109
- * the field REPLACES the server's defaults rather than extending them, so a
110
- * declaration-only set can silently lose `fetch`.
111
- */
112
- function withFetch(opts) {
113
- const derived = opts.capabilities ?? [];
114
- return {
115
- ...opts,
116
- capabilities: derived.includes('fetch')
117
- ? derived
118
- : [...derived, 'fetch'],
119
- };
120
- }
121
- /**
122
- * Settle a set of per-host capture attempts under ONE overall deadline.
123
- *
124
- * `Promise.any` alone is not enough. The two declared hosts are documented as
125
- * raced, but the bridge serializes them behind its single connection, so the
126
- * wait was the sum rather than the max — measured live at 14.4s for a 5s window
127
- * and 36s for a 20s one. At the 30s default that lands past the 60s request
128
- * timeout an MCP client uses by default, so the host gave up before this code
129
- * could report the real reason. The window is a promise about total wait, and
130
- * this is what keeps it.
131
- *
132
- * Exported for the test that pins that bound.
133
- */
134
- export async function raceCaptures(attempts, windowMs) {
135
- // A rejection that loses the race must not surface as an unhandled rejection
136
- // once the winner has already settled the caller.
137
- for (const a of attempts)
138
- a.catch(() => { });
139
- let timer;
140
- const expiry = new Promise((_, reject) => {
141
- timer = setTimeout(() => reject(new Error(`timed out after ${Math.round(windowMs / 1000)}s waiting for the page to ` +
142
- 'make a request we could read')), windowMs);
143
- });
144
- try {
145
- // `any` resolves on the first SUCCESS rather than the first settle, so the
146
- // host the user is not on failing does not sink the one they are on.
147
- return await Promise.race([
148
- Promise.any(attempts).catch((e) => {
149
- throw new Error(e instanceof AggregateError
150
- ? e.errors.map((x) => x.message).join('; ')
151
- : e.message);
152
- }),
153
- expiry,
154
- ]);
155
- }
156
- finally {
157
- clearTimeout(timer);
158
- }
159
- }
160
- /** Normalise a captured header to a bare token, dropping any `Bearer ` prefix. */
161
- export function stripBearer(raw) {
162
- const m = /^\s*Bearer\s+(.+)$/i.exec(raw);
163
- return (m ? m[1] : raw).trim();
164
- }
165
100
  /**
166
101
  * Capture an Outlook access token from the user's signed-in browser tab.
167
102
  *
@@ -171,54 +106,23 @@ export function stripBearer(raw) {
171
106
  * Returns the bare token (no `Bearer ` prefix).
172
107
  */
173
108
  export async function captureTokenViaFetchproxy() {
174
- const transport = createFetchproxyTransport({
175
- ...withFetch(createBootstrapOpts({
176
- domains: [...TRUST_DOMAINS],
177
- bootstrap: {
178
- captureHeaders: [{ ...CAPTURE_DECL_CLOUD }, { ...CAPTURE_DECL_OFFICE }],
179
- },
180
- })),
109
+ // `captureBearerHeader` declares only `capture_request_header` beside the
110
+ // `fetch` base verb, derives the bridge deadline from the window (window +
111
+ // 15 s, never under 30 s) so a raised window is not cut off early, starts the
112
+ // transport (skipping `start()` makes the first verb throw "ensureConnected
113
+ // called before listen()", which reads like a pairing problem), rejects a
114
+ // header too short to be a token (real ones are ~5 KB of JWT), and closes
115
+ // the transport whatever happened.
116
+ return captureBearerHeader({
117
+ captures: [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE],
118
+ domains: TRUST_DOMAINS,
119
+ windowMs: CAPTURE_TIMEOUT_MS,
181
120
  port: getWsPort(),
182
- fetchTimeoutMs: BRIDGE_DEADLINE_MS,
183
121
  serverName: PACKAGE_NAME,
184
122
  version: VERSION,
123
+ 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.',
185
127
  });
186
- try {
187
- // `start()` loads the identity keypair. It does not bind the port or dial —
188
- // that stays lazy until the first verb — but skipping it makes the first
189
- // verb throw "ensureConnected called before listen()", which reads exactly
190
- // like a pairing problem and is not one.
191
- await transport.start();
192
- const attempts = [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE].map((decl) => transport.server
193
- .captureRequestHeader({ ...decl, timeoutMs: CAPTURE_TIMEOUT_MS })
194
- .then((raw) => {
195
- if (typeof raw !== 'string') {
196
- throw new Error(`${decl.host}: capture returned no header`);
197
- }
198
- const token = stripBearer(raw);
199
- if (token.length < MIN_TOKEN_LENGTH) {
200
- throw new Error(`${decl.host}: authorization header was present but too short to be a ` +
201
- `token (${token.length} chars)`);
202
- }
203
- return token;
204
- }));
205
- try {
206
- return await raceCaptures(attempts, CAPTURE_TIMEOUT_MS);
207
- }
208
- catch (e) {
209
- const detail = e.message;
210
- throw new Error(`could not capture an Outlook token from the browser (${detail}). ` +
211
- 'Open a signed-in Outlook tab (outlook.cloud.microsoft or ' +
212
- 'outlook.office.com), leave it in the foreground for a moment so the ' +
213
- 'page makes a request, and retry.');
214
- }
215
- }
216
- finally {
217
- try {
218
- await transport.close();
219
- }
220
- catch {
221
- /* the token, or the original failure, is what matters */
222
- }
223
- }
224
128
  }
package/dist/bundle.js CHANGED
@@ -35421,10 +35421,33 @@ function redactJsonCookie(quote) {
35421
35421
  function redactSecrets(text) {
35422
35422
  return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(GOOGLE_OAUTH_TOKEN_RE, "[REDACTED]").replace(HEADER_SECRET_RE, "$1[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(OAUTH_CODE_RE, "$1[REDACTED]").replace(FORM_PASSWORD_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_COOKIE_DQ_RE, redactJsonCookie('"')).replace(JSON_COOKIE_SQ_RE, redactJsonCookie("'")).replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_NUM_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
35423
35423
  }
35424
- function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
35424
+ function redactValues(text, values, opts = {}) {
35425
+ const placeholder = opts.placeholder ?? "[REDACTED]";
35426
+ const minLength = Math.max(1, opts.minLength ?? 1);
35427
+ const needles = /* @__PURE__ */ new Set();
35428
+ for (const value of values) {
35429
+ if (typeof value !== "string" || value.length < minLength)
35430
+ continue;
35431
+ needles.add(value);
35432
+ if (opts.jsonEscaped !== false) {
35433
+ const escaped = JSON.stringify(value).slice(1, -1);
35434
+ if (escaped !== value && escaped.length >= minLength)
35435
+ needles.add(escaped);
35436
+ }
35437
+ }
35438
+ let out = String(text ?? "");
35439
+ for (const needle of [...needles].sort((a, b) => b.length - a.length)) {
35440
+ if (out.includes(needle))
35441
+ out = out.split(needle).join(placeholder);
35442
+ }
35443
+ return out;
35444
+ }
35445
+ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX, opts = {}) {
35425
35446
  const str = text === null || text === void 0 ? "" : String(text);
35426
35447
  const cap = Math.max(ERROR_REDACTION_INPUT_MAX, max);
35427
- const redacted = redactSecrets(str.length > cap ? str.slice(0, cap) : str);
35448
+ const capped = str.length > cap ? str.slice(0, cap) : str;
35449
+ const scrubbed = opts.values !== void 0 ? redactValues(capped, opts.values, opts.placeholder !== void 0 ? { placeholder: opts.placeholder } : {}) : capped;
35450
+ const redacted = redactSecrets(scrubbed);
35428
35451
  if (redacted.length <= max)
35429
35452
  return redacted;
35430
35453
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
@@ -35573,6 +35596,16 @@ var init_zod = __esm({
35573
35596
  }
35574
35597
  });
35575
35598
 
35599
+ // node_modules/@chrischall/mcp-utils/dist/response/media.js
35600
+ var MEDIA_NOUN, MEDIA_QUALIFIER, MEDIA_KEY;
35601
+ var init_media = __esm({
35602
+ "node_modules/@chrischall/mcp-utils/dist/response/media.js"() {
35603
+ MEDIA_NOUN = "(?:avatar|picture|photo|thumbnail|thumb|image|icon|banner|profile_pic(?:ture)?|logo)";
35604
+ MEDIA_QUALIFIER = "(?:primary|secondary|main|default|cover|hero|profile|master|rendered|small|medium|large|full|original|tall)";
35605
+ MEDIA_KEY = new RegExp(`^(?:(?:${MEDIA_QUALIFIER}|${MEDIA_NOUN})[_-]?)?${MEDIA_NOUN}s?(?:[_-]?(?:link|uri|url|src)s?)?$`, "i");
35606
+ }
35607
+ });
35608
+
35576
35609
  // node_modules/@chrischall/mcp-utils/dist/response/view.js
35577
35610
  function viewParam(honoured, opts = {}) {
35578
35611
  if (honoured.length < 2) {
@@ -35611,6 +35644,7 @@ var VIEWS, DEFAULT_VIEW, BLURB;
35611
35644
  var init_view = __esm({
35612
35645
  "node_modules/@chrischall/mcp-utils/dist/response/view.js"() {
35613
35646
  init_zod();
35647
+ init_media();
35614
35648
  VIEWS = ["compact", "full", "raw"];
35615
35649
  DEFAULT_VIEW = "compact";
35616
35650
  BLURB = {
@@ -35621,16 +35655,6 @@ var init_view = __esm({
35621
35655
  }
35622
35656
  });
35623
35657
 
35624
- // node_modules/@chrischall/mcp-utils/dist/response/media.js
35625
- var MEDIA_NOUN, MEDIA_QUALIFIER, MEDIA_KEY;
35626
- var init_media = __esm({
35627
- "node_modules/@chrischall/mcp-utils/dist/response/media.js"() {
35628
- MEDIA_NOUN = "(?:avatar|picture|photo|thumbnail|thumb|image|icon|banner|profile_pic(?:ture)?|logo)";
35629
- MEDIA_QUALIFIER = "(?:primary|secondary|main|default|cover|hero|profile|master|rendered|small|medium|large|full|original|tall)";
35630
- MEDIA_KEY = new RegExp(`^(?:(?:${MEDIA_QUALIFIER}|${MEDIA_NOUN})[_-]?)?${MEDIA_NOUN}s?(?:[_-]?(?:link|uri|url|src)s?)?$`, "i");
35631
- }
35632
- });
35633
-
35634
35658
  // node_modules/@chrischall/mcp-utils/dist/response/untrusted.js
35635
35659
  function isPlainObject3(value) {
35636
35660
  if (value === null || typeof value !== "object" || Array.isArray(value))
@@ -36892,6 +36916,126 @@ var init_url_safety = __esm({
36892
36916
  }
36893
36917
  });
36894
36918
 
36919
+ // node_modules/@chrischall/mcp-utils/dist/http/transport-failure.js
36920
+ function codeOf(e) {
36921
+ if (e === null || typeof e !== "object")
36922
+ return void 0;
36923
+ const code2 = e.code;
36924
+ return typeof code2 === "string" && code2 !== "" ? code2 : void 0;
36925
+ }
36926
+ function causes(err) {
36927
+ const out = [];
36928
+ let at = err;
36929
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && at !== void 0 && at !== null; depth++) {
36930
+ out.push(at);
36931
+ if (at instanceof AggregateError)
36932
+ out.push(...at.errors);
36933
+ at = at.cause;
36934
+ }
36935
+ return out;
36936
+ }
36937
+ function transportCauseCode(err) {
36938
+ for (const e of causes(err)) {
36939
+ const code2 = codeOf(e);
36940
+ if (code2 !== void 0)
36941
+ return code2;
36942
+ }
36943
+ return void 0;
36944
+ }
36945
+ function causeMessage(err) {
36946
+ const chain = causes(err).slice(1);
36947
+ for (let i = chain.length - 1; i >= 0; i--) {
36948
+ const e = chain[i];
36949
+ if (e instanceof Error && e.message !== "")
36950
+ return e.message;
36951
+ }
36952
+ return void 0;
36953
+ }
36954
+ function isFetchFailedError(err) {
36955
+ return err instanceof TypeError && err.message === "fetch failed";
36956
+ }
36957
+ function proxyRefusalStatus(err) {
36958
+ for (const e of causes(err)) {
36959
+ if (codeOf(e) === "ERR_PROXY_TUNNEL") {
36960
+ const m = /\b(\d{3})\b/.exec(e instanceof Error ? e.message.slice(0, 500) : "");
36961
+ return m ? Number(m[1]) : "unknown";
36962
+ }
36963
+ if (e instanceof Error) {
36964
+ const m = /^Proxy response \((\d{3})\) !== 200 when HTTP Tunneling/.exec(e.message);
36965
+ if (m)
36966
+ return Number(m[1]);
36967
+ }
36968
+ }
36969
+ return void 0;
36970
+ }
36971
+ function classify(err, code2) {
36972
+ const refused = proxyRefusalStatus(err);
36973
+ if (refused !== void 0) {
36974
+ return { cls: refused === 403 || refused === 407 || refused === "unknown" ? "egress" : "proxy", proxyStatus: refused };
36975
+ }
36976
+ if (code2 !== void 0 && DNS_CODES.has(code2))
36977
+ return { cls: "dns" };
36978
+ if (code2 !== void 0 && CERT_CODES.has(code2))
36979
+ return { cls: "tls" };
36980
+ if (code2 !== void 0 && CONNECT_CODES.has(code2))
36981
+ return { cls: "connect" };
36982
+ return { cls: "other" };
36983
+ }
36984
+ function describeTransportFailure(err, url2, service, opts = {}) {
36985
+ if (!isFetchFailedError(err))
36986
+ return void 0;
36987
+ let host;
36988
+ try {
36989
+ host = new URL(url2).hostname;
36990
+ } catch {
36991
+ host = "(unknown host)";
36992
+ }
36993
+ const code2 = transportCauseCode(err);
36994
+ const detail = [code2, causeMessage(err)].filter((s) => s !== void 0);
36995
+ const why = detail.length > 0 ? ` (${[...new Set(detail)].join(": ")})` : "";
36996
+ const { cls, proxyStatus } = classify(err, code2);
36997
+ const ctx = { host, service, code: code2, ...proxyStatus !== void 0 ? { proxyStatus } : {} };
36998
+ const hint = (opts.hints?.[cls] ?? DEFAULT_HINTS[cls])(host, ctx);
36999
+ return new McpToolError(`Could not reach ${service} at ${host}${why}.`, { kind: "transport", hint, cause: err });
37000
+ }
37001
+ var MAX_CAUSE_DEPTH, DNS_CODES, CONNECT_CODES, CERT_CODES, DEFAULT_HINTS;
37002
+ var init_transport_failure = __esm({
37003
+ "node_modules/@chrischall/mcp-utils/dist/http/transport-failure.js"() {
37004
+ init_errors3();
37005
+ MAX_CAUSE_DEPTH = 5;
37006
+ DNS_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_FAIL", "EAI_NONAME"]);
37007
+ CONNECT_CODES = /* @__PURE__ */ new Set([
37008
+ "ECONNREFUSED",
37009
+ "ECONNRESET",
37010
+ "ETIMEDOUT",
37011
+ "ENETUNREACH",
37012
+ "EHOSTUNREACH",
37013
+ "EPIPE",
37014
+ "UND_ERR_CONNECT_TIMEOUT",
37015
+ "UND_ERR_SOCKET",
37016
+ "UND_ERR_HEADERS_TIMEOUT"
37017
+ ]);
37018
+ CERT_CODES = /* @__PURE__ */ new Set([
37019
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
37020
+ "UNABLE_TO_GET_ISSUER_CERT",
37021
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
37022
+ "SELF_SIGNED_CERT_IN_CHAIN",
37023
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
37024
+ "CERT_HAS_EXPIRED",
37025
+ "CERT_NOT_YET_VALID",
37026
+ "ERR_TLS_CERT_ALTNAME_INVALID"
37027
+ ]);
37028
+ DEFAULT_HINTS = {
37029
+ egress: (host) => `The outbound proxy this server runs behind refused the connection to ${host}: its egress policy does not allow that host. On a hosted (mcp-host) registration, the host must be on the registration's egress allow-list \u2014 the owner accepts the manifest's egress proposal (or adds the host) on the registration, then retries. Elsewhere, ask whoever runs the proxy to allow it.`,
37030
+ proxy: (host, ctx) => `The outbound proxy this server runs behind could not open a connection to ${host} (it answered ${String(ctx.proxyStatus)}). Retry; if it persists, check the proxy can reach the host.`,
37031
+ dns: (host) => `This machine could not resolve ${host}. Check DNS and VPN: a split-tunnel VPN that resolves some of a service's hosts but not others fails exactly like this. \`nslookup ${host}\` on the machine running this server tells you which.`,
37032
+ tls: (host) => `Node did not trust the TLS certificate ${host} presented. On a corporate network this is usually TLS inspection: export your proxy's root CA as PEM and start this server with NODE_EXTRA_CA_CERTS=/path/to/ca.pem (Node does not read the system keychain). If the certificate is expired or not yet valid, also check the machine's clock.`,
37033
+ connect: (host, ctx) => `The connection to ${host} was refused, reset or timed out before ${ctx.service} answered \u2014 usually a firewall, or a network that only lets traffic out through a proxy. Node's fetch ignores proxy settings unless told: start this server with HTTPS_PROXY=<your proxy URL> and NODE_USE_ENV_PROXY=1 (Node 22.21+ / 24.5+), or allow the host through the firewall.`,
37034
+ other: (host) => `No response from ${host}. Check this machine can reach it (VPN, proxy, firewall); \`curl -sI https://${host}/\` from the same machine is a quick test.`
37035
+ };
37036
+ }
37037
+ });
37038
+
36895
37039
  // node_modules/@chrischall/mcp-utils/dist/internal/retry-after.js
36896
37040
  function retryAfterToMs(header, now = Date.now()) {
36897
37041
  if (header == null)
@@ -36941,6 +37085,27 @@ function cancelBody(res) {
36941
37085
  } catch {
36942
37086
  }
36943
37087
  }
37088
+ function writeOutcomeStatus(err) {
37089
+ const status = err.cause?.status;
37090
+ return typeof status === "number" ? status : void 0;
37091
+ }
37092
+ function unknownOutcomeReason(err, opts = {}) {
37093
+ if (err.timedOut)
37094
+ return "it did not answer in time";
37095
+ const status = writeOutcomeStatus(err);
37096
+ if (status !== void 0)
37097
+ return `it answered HTTP ${status} after ${opts.sent ?? "the request"} was sent`;
37098
+ return "the connection dropped";
37099
+ }
37100
+ async function writeOrUnknown(run, onUnknown) {
37101
+ try {
37102
+ return { ok: true, value: await run() };
37103
+ } catch (err) {
37104
+ if (!(err instanceof WriteOutcomeUnknownError))
37105
+ throw err;
37106
+ return { ok: false, result: await onUnknown(err, unknownOutcomeReason(err)) };
37107
+ }
37108
+ }
36944
37109
  function headerOf(headers, name) {
36945
37110
  if (headers === void 0)
36946
37111
  return void 0;
@@ -37209,7 +37374,7 @@ function createApiClient(opts) {
37209
37374
  const headers = {
37210
37375
  Accept: "application/json",
37211
37376
  ...withBody && contentType !== void 0 ? { "Content-Type": contentType } : {},
37212
- ...opts.baseHeaders,
37377
+ ...typeof opts.baseHeaders === "function" ? opts.baseHeaders() : opts.baseHeaders,
37213
37378
  ...authHeader(token || void 0),
37214
37379
  ...opt.headers
37215
37380
  };
@@ -37323,24 +37488,62 @@ function createApiClient(opts) {
37323
37488
  throw err;
37324
37489
  }
37325
37490
  }
37326
- const fetchJson = (method, path, opt = {}) => guardWrite(method, opt, (dispatch) => fetchJsonOnce(method, path, opt, dispatch));
37327
- const fetchHtml = (method, path, opt = {}) => guardWrite(method, opt, (dispatch) => fetchHtmlOnce(method, path, opt, dispatch));
37328
- const fetchRaw = (method, path, opt = {}) => guardWrite(method, opt, (dispatch) => fetchRawOnce(method, path, opt, dispatch));
37491
+ function withIdempotency(opt) {
37492
+ const name = opts.idempotencyHeader;
37493
+ if (name === void 0 || name === "")
37494
+ return opt;
37495
+ const lower2 = name.toLowerCase();
37496
+ if (opt.headers && Object.keys(opt.headers).some((k) => k.toLowerCase() === lower2))
37497
+ return opt;
37498
+ const key = opt.idempotencyKey ?? globalThis.crypto.randomUUID();
37499
+ return { ...opt, headers: { ...opt.headers, [name]: key } };
37500
+ }
37501
+ async function errorFor(res, text, method, path, retried) {
37502
+ const edge = detectEdgeBlock({ body: text, headers: res.headers, status: res.status });
37503
+ if (edge)
37504
+ return new EdgeBlockedError(res.status, edge.vendor, { service, method, path });
37505
+ if (opts.onErrorResponse) {
37506
+ const decision = await opts.onErrorResponse({ status: res.status, method, path, headers: res.headers, body: text, retried });
37507
+ if (decision instanceof Error)
37508
+ return decision;
37509
+ if (decision === "retry" && !retried)
37510
+ return "retry";
37511
+ }
37512
+ return httpError(res, text, method, path, service);
37513
+ }
37514
+ const fetchJson = (method, path, opt = {}) => {
37515
+ const o = withIdempotency(opt);
37516
+ return guardWrite(method, o, (dispatch) => fetchJsonOnce(method, path, o, dispatch));
37517
+ };
37518
+ const fetchHtml = (method, path, opt = {}) => {
37519
+ const o = withIdempotency(opt);
37520
+ return guardWrite(method, o, (dispatch) => fetchHtmlOnce(method, path, o, dispatch));
37521
+ };
37522
+ const fetchRaw = (method, path, opt = {}) => {
37523
+ const o = withIdempotency(opt);
37524
+ return guardWrite(method, o, (dispatch) => fetchRawOnce(method, path, o, dispatch));
37525
+ };
37329
37526
  async function fetchJsonOnce(method, path, opt, dispatch) {
37330
37527
  const maxBytes = capOf(opt);
37331
- const attempt = await send(method, path, opt, dispatch);
37332
- const res = attempt.res;
37333
- if (res.status === 401)
37334
- throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
37335
- if (res.status === 429)
37336
- throw await rateLimitedError(attempt, method, path, maxBytes);
37337
- if (res.status === 204) {
37338
- discard(attempt);
37339
- return parseJsonBody("", { expect: opt.expect, service, method, path, status: 204 });
37340
- }
37341
- if (!res.ok) {
37342
- throw httpError(res, await readErrorText(attempt, maxBytes), method, path, service);
37528
+ let attempt;
37529
+ for (let retried = false; ; retried = true) {
37530
+ attempt = await send(method, path, opt, dispatch);
37531
+ const res2 = attempt.res;
37532
+ if (res2.status === 401)
37533
+ throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
37534
+ if (res2.status === 429)
37535
+ throw await rateLimitedError(attempt, method, path, maxBytes);
37536
+ if (res2.status === 204) {
37537
+ discard(attempt);
37538
+ return parseJsonBody("", { expect: opt.expect, service, method, path, status: 204 });
37539
+ }
37540
+ if (res2.ok)
37541
+ break;
37542
+ const failure2 = await errorFor(res2, await readErrorText(attempt, maxBytes), method, path, retried);
37543
+ if (failure2 !== "retry")
37544
+ throw failure2;
37343
37545
  }
37546
+ const res = attempt.res;
37344
37547
  const text = await readText(attempt, maxBytes);
37345
37548
  return parseJsonBody(text, {
37346
37549
  expect: opt.expect,
@@ -37354,34 +37557,43 @@ function createApiClient(opts) {
37354
37557
  async function fetchHtmlOnce(method, path, opt, dispatch) {
37355
37558
  const maxBytes = capOf(opt);
37356
37559
  const headers = { Accept: "text/html,*/*", ...opt.headers };
37357
- const attempt = await send(method, path, { ...opt, headers }, dispatch);
37358
- const res = attempt.res;
37359
- if (res.status === 401)
37360
- throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
37361
- if (res.status === 429)
37362
- throw await rateLimitedError(attempt, method, path, maxBytes);
37363
- if (!res.ok) {
37364
- throw httpError(res, await readErrorText(attempt, maxBytes), method, path, service);
37560
+ for (let retried = false; ; retried = true) {
37561
+ const attempt = await send(method, path, { ...opt, headers }, dispatch);
37562
+ const res = attempt.res;
37563
+ if (res.status === 401)
37564
+ throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
37565
+ if (res.status === 429)
37566
+ throw await rateLimitedError(attempt, method, path, maxBytes);
37567
+ if (res.ok)
37568
+ return readText(attempt, maxBytes);
37569
+ const failure2 = await errorFor(res, await readErrorText(attempt, maxBytes), method, path, retried);
37570
+ if (failure2 !== "retry")
37571
+ throw failure2;
37365
37572
  }
37366
- return readText(attempt, maxBytes);
37367
37573
  }
37368
37574
  async function fetchRawOnce(method, path, opt, dispatch) {
37369
37575
  const maxBytes = capOf(opt);
37370
37576
  const headers = { Accept: "*/*", ...opt.headers };
37371
- const attempt = await send(method, path, { ...opt, headers }, dispatch);
37372
- const res = attempt.res;
37373
- if (res.status === 401)
37374
- throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
37375
- if (res.status === 429)
37376
- throw await rateLimitedError(attempt, method, path, maxBytes);
37377
- if (!res.ok) {
37577
+ let attempt;
37578
+ for (let retried = false; ; retried = true) {
37579
+ attempt = await send(method, path, { ...opt, headers }, dispatch);
37580
+ const res2 = attempt.res;
37581
+ if (res2.status === 401)
37582
+ throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
37583
+ if (res2.status === 429)
37584
+ throw await rateLimitedError(attempt, method, path, maxBytes);
37585
+ if (res2.ok)
37586
+ break;
37378
37587
  const text = await readText(attempt, maxBytes).catch((err) => {
37379
37588
  if (err instanceof RequestTimeoutError)
37380
37589
  throw err;
37381
37590
  return "";
37382
37591
  });
37383
- throw httpError(res, text, method, path, service);
37592
+ const failure2 = await errorFor(res2, text, method, path, retried);
37593
+ if (failure2 !== "retry")
37594
+ throw failure2;
37384
37595
  }
37596
+ const res = attempt.res;
37385
37597
  const bytes = await readBytes(attempt, maxBytes);
37386
37598
  return {
37387
37599
  status: res.status,
@@ -37462,6 +37674,7 @@ var init_http = __esm({
37462
37674
  init_net_atoms();
37463
37675
  init_fetch_bounded();
37464
37676
  init_url_safety();
37677
+ init_transport_failure();
37465
37678
  init_body_limit();
37466
37679
  init_net_atoms();
37467
37680
  init_url_safety();
@@ -37709,7 +37922,7 @@ var init_version = __esm({
37709
37922
  "src/version.ts"() {
37710
37923
  "use strict";
37711
37924
  PACKAGE_NAME = "office-outlook-mcp";
37712
- VERSION = "0.6.1";
37925
+ VERSION = "0.6.2";
37713
37926
  }
37714
37927
  });
37715
37928
 
@@ -48055,6 +48268,71 @@ function createBootstrapOpts(args) {
48055
48268
  ...nonEmpty(b.domSelectors) ? { domSelectors: b.domSelectors } : {}
48056
48269
  };
48057
48270
  }
48271
+ function stripBearer(raw) {
48272
+ const m = /^\s*Bearer\s+(.+)$/i.exec(raw);
48273
+ return (m?.[1] ?? raw).trim();
48274
+ }
48275
+ async function raceCaptures(attempts, windowMs) {
48276
+ for (const a of attempts)
48277
+ a.catch(() => {
48278
+ });
48279
+ let timer;
48280
+ const expiry = new Promise((_resolve, reject) => {
48281
+ timer = setTimeout(() => reject(new Error(`timed out after ${Math.round(windowMs / 1e3)}s waiting for the page to make a request we could read`)), windowMs);
48282
+ });
48283
+ try {
48284
+ return await Promise.race([
48285
+ Promise.any(attempts).catch((e) => {
48286
+ throw new Error(e instanceof AggregateError ? e.errors.map((x) => messageOf(x)).join("; ") : messageOf(e));
48287
+ }),
48288
+ expiry
48289
+ ]);
48290
+ } finally {
48291
+ clearTimeout(timer);
48292
+ }
48293
+ }
48294
+ async function captureBearerHeader(opts) {
48295
+ if (opts.captures.length === 0)
48296
+ throw new Error("captureBearerHeader: at least one capture declaration is required.");
48297
+ const minLength = opts.minLength ?? 100;
48298
+ const normalise = opts.normalise ?? stripBearer;
48299
+ const domains = opts.domains ?? [...new Set(opts.captures.map((c) => c.host))];
48300
+ const build2 = opts.createTransport ?? ((o) => createFetchproxyTransport(o));
48301
+ const transport = build2({
48302
+ ...opts.transportOptions,
48303
+ ...createBootstrapOpts({ domains: [...domains], bootstrap: { captureHeaders: opts.captures.map((c) => ({ ...c })) } }),
48304
+ ...opts.port !== void 0 ? { port: opts.port } : {},
48305
+ // Both: the explicit deadline for every bridge version, and the declared
48306
+ // window so the transport's own derivation agrees with it.
48307
+ fetchTimeoutMs: deadlineForCaptureWindow(void 0, opts.windowMs) ?? SERVER_DEFAULT_FETCH_TIMEOUT_MS,
48308
+ captureWindowMs: opts.windowMs,
48309
+ serverName: opts.serverName,
48310
+ version: opts.version
48311
+ });
48312
+ const one = async (decl) => {
48313
+ const raw = await transport.server.captureRequestHeader({ ...decl, timeoutMs: opts.windowMs });
48314
+ if (typeof raw !== "string")
48315
+ throw new Error(`${decl.host}: capture returned no header`);
48316
+ const token = normalise(raw);
48317
+ if (token.length < minLength) {
48318
+ throw new Error(`${decl.host}: ${decl.headerName} header was present but too short to be a token (${token.length} chars)`);
48319
+ }
48320
+ return token;
48321
+ };
48322
+ try {
48323
+ await transport.start();
48324
+ try {
48325
+ return opts.captures.length === 1 ? await one(opts.captures[0]) : await raceCaptures(opts.captures.map(one), opts.windowMs);
48326
+ } catch (e) {
48327
+ throw new Error(`could not capture ${opts.what} from the browser (${messageOf(e)}). ${opts.guidance}`);
48328
+ }
48329
+ } finally {
48330
+ try {
48331
+ await transport.close();
48332
+ } catch {
48333
+ }
48334
+ }
48335
+ }
48058
48336
  var PLACEHOLDER_RE2, SERVER_DEFAULT_FETCH_TIMEOUT_MS, CAPTURE_DEADLINE_MARGIN_MS;
48059
48337
  var init_fetchproxy = __esm({
48060
48338
  "node_modules/@chrischall/mcp-utils/dist/fetchproxy/index.js"() {
@@ -48083,97 +48361,26 @@ __export(auth_fetchproxy_exports, {
48083
48361
  function getWsPort() {
48084
48362
  return readPortEnv("OUTLOOK_WS_PORT", DEFAULT_WS_PORT);
48085
48363
  }
48086
- function withFetch(opts) {
48087
- const derived2 = opts.capabilities ?? [];
48088
- return {
48089
- ...opts,
48090
- capabilities: derived2.includes("fetch") ? derived2 : [...derived2, "fetch"]
48091
- };
48092
- }
48093
- async function raceCaptures(attempts, windowMs) {
48094
- for (const a of attempts) a.catch(() => {
48095
- });
48096
- let timer;
48097
- const expiry = new Promise((_, reject) => {
48098
- timer = setTimeout(
48099
- () => reject(
48100
- new Error(
48101
- `timed out after ${Math.round(windowMs / 1e3)}s waiting for the page to make a request we could read`
48102
- )
48103
- ),
48104
- windowMs
48105
- );
48106
- });
48107
- try {
48108
- return await Promise.race([
48109
- Promise.any(attempts).catch((e) => {
48110
- throw new Error(
48111
- e instanceof AggregateError ? e.errors.map((x) => x.message).join("; ") : e.message
48112
- );
48113
- }),
48114
- expiry
48115
- ]);
48116
- } finally {
48117
- clearTimeout(timer);
48118
- }
48119
- }
48120
- function stripBearer(raw) {
48121
- const m = /^\s*Bearer\s+(.+)$/i.exec(raw);
48122
- return (m ? m[1] : raw).trim();
48123
- }
48124
48364
  async function captureTokenViaFetchproxy() {
48125
- const transport = createFetchproxyTransport({
48126
- ...withFetch(
48127
- createBootstrapOpts({
48128
- domains: [...TRUST_DOMAINS],
48129
- bootstrap: {
48130
- captureHeaders: [{ ...CAPTURE_DECL_CLOUD }, { ...CAPTURE_DECL_OFFICE }]
48131
- }
48132
- })
48133
- ),
48365
+ return captureBearerHeader({
48366
+ captures: [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE],
48367
+ domains: TRUST_DOMAINS,
48368
+ windowMs: CAPTURE_TIMEOUT_MS,
48134
48369
  port: getWsPort(),
48135
- fetchTimeoutMs: BRIDGE_DEADLINE_MS,
48136
48370
  serverName: PACKAGE_NAME,
48137
- version: VERSION
48371
+ version: VERSION,
48372
+ what: "an Outlook token",
48373
+ 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."
48138
48374
  });
48139
- try {
48140
- await transport.start();
48141
- const attempts = [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE].map(
48142
- (decl) => transport.server.captureRequestHeader({ ...decl, timeoutMs: CAPTURE_TIMEOUT_MS }).then((raw) => {
48143
- if (typeof raw !== "string") {
48144
- throw new Error(`${decl.host}: capture returned no header`);
48145
- }
48146
- const token = stripBearer(raw);
48147
- if (token.length < MIN_TOKEN_LENGTH) {
48148
- throw new Error(
48149
- `${decl.host}: authorization header was present but too short to be a token (${token.length} chars)`
48150
- );
48151
- }
48152
- return token;
48153
- })
48154
- );
48155
- try {
48156
- return await raceCaptures(attempts, CAPTURE_TIMEOUT_MS);
48157
- } catch (e) {
48158
- const detail = e.message;
48159
- throw new Error(
48160
- `could not capture an Outlook token from the browser (${detail}). 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.`
48161
- );
48162
- }
48163
- } finally {
48164
- try {
48165
- await transport.close();
48166
- } catch {
48167
- }
48168
- }
48169
48375
  }
48170
- var DEFAULT_WS_PORT, CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE, CAPTURE_HOSTS, TRUST_DOMAINS, CAPTURE_TIMEOUT_MS, BRIDGE_DEADLINE_MS, MIN_TOKEN_LENGTH;
48376
+ var DEFAULT_WS_PORT, CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE, CAPTURE_HOSTS, TRUST_DOMAINS, CAPTURE_TIMEOUT_MS;
48171
48377
  var init_auth_fetchproxy = __esm({
48172
48378
  "src/auth-fetchproxy.ts"() {
48173
48379
  "use strict";
48174
48380
  init_fetchproxy();
48175
48381
  init_dist2();
48176
48382
  init_version();
48383
+ init_fetchproxy();
48177
48384
  DEFAULT_WS_PORT = 37149;
48178
48385
  CAPTURE_DECL_CLOUD = {
48179
48386
  host: "outlook.cloud.microsoft",
@@ -48188,8 +48395,6 @@ var init_auth_fetchproxy = __esm({
48188
48395
  CAPTURE_HOSTS = [CAPTURE_DECL_CLOUD.host, CAPTURE_DECL_OFFICE.host];
48189
48396
  TRUST_DOMAINS = [...CAPTURE_HOSTS];
48190
48397
  CAPTURE_TIMEOUT_MS = readTtlMsEnv("OUTLOOK_CAPTURE_TIMEOUT", 3e4);
48191
- BRIDGE_DEADLINE_MS = Math.max(CAPTURE_TIMEOUT_MS + 15e3, 3e4);
48192
- MIN_TOKEN_LENGTH = 100;
48193
48398
  }
48194
48399
  });
48195
48400
 
@@ -48771,6 +48976,46 @@ var TokenManager = class {
48771
48976
  return res;
48772
48977
  }
48773
48978
  };
48979
+ var RECAPTURE_SENTINEL = "fetchproxy:recapture";
48980
+ var UNKNOWN_CAPTURED_TOKEN_TTL_MS = 10 * 60 * 1e3;
48981
+ function capturedBearerTokens(accessToken, opts = {}) {
48982
+ const now = opts.now ?? Date.now;
48983
+ let expiresAt;
48984
+ try {
48985
+ expiresAt = decodeJwtExp(accessToken) * 1e3;
48986
+ } catch {
48987
+ expiresAt = now() + (opts.unknownTtlMs ?? UNKNOWN_CAPTURED_TOKEN_TTL_MS);
48988
+ }
48989
+ return { accessToken, refreshToken: RECAPTURE_SENTINEL, expiresAt };
48990
+ }
48991
+ function createCapturedTokenManager(opts) {
48992
+ const { direct } = opts;
48993
+ const wrap = (token) => capturedBearerTokens(token, {
48994
+ ...opts.unknownTtlMs !== void 0 ? { unknownTtlMs: opts.unknownTtlMs } : {},
48995
+ ...opts.now !== void 0 ? { now: opts.now } : {}
48996
+ });
48997
+ const persistence = direct === void 0 ? opts.persistence ?? void 0 : void 0;
48998
+ return new TokenManager({
48999
+ initial: async () => wrap(direct ?? await opts.capture()),
49000
+ refresh: async () => {
49001
+ if (direct !== void 0) {
49002
+ throw opts.onDirectRejected ? opts.onDirectRejected() : new McpToolError("The directly supplied access token was rejected (401).", {
49003
+ status: 401,
49004
+ kind: "credential_rejected",
49005
+ hint: "A directly supplied token cannot be refreshed automatically. Replace it with a current one, or unset it to capture from a signed-in browser tab instead."
49006
+ });
49007
+ }
49008
+ return wrap(await opts.capture());
49009
+ },
49010
+ ...persistence !== void 0 ? { persistence } : {},
49011
+ // Never fatal: the token is in hand and re-capturable.
49012
+ onPersistError: (err) => {
49013
+ opts.onPersistError?.(err);
49014
+ },
49015
+ ...opts.skewMs !== void 0 ? { skewMs: opts.skewMs } : {},
49016
+ ...opts.now !== void 0 ? { now: opts.now } : {}
49017
+ });
49018
+ }
48774
49019
 
48775
49020
  // src/token-cache.ts
48776
49021
  init_dist2();
@@ -48782,11 +49027,6 @@ function tokenCachePath(env = process.env) {
48782
49027
  fileName: "token.json"
48783
49028
  });
48784
49029
  }
48785
- function isToken(raw) {
48786
- if (raw === null || typeof raw !== "object") return false;
48787
- const t = raw;
48788
- return typeof t.accessToken === "string" && t.accessToken !== "" && typeof t.expiresAt === "number" && (t.refreshToken === void 0 || typeof t.refreshToken === "string");
48789
- }
48790
49030
  function bindingFor(env) {
48791
49031
  if (readEnvVar("OUTLOOK_ACCESS_TOKEN", { env }) !== void 0) return null;
48792
49032
  if (parseBoolEnv("OUTLOOK_DISABLE_FETCHPROXY", { env })) return null;
@@ -48799,7 +49039,7 @@ function createTokenCache(env = process.env) {
48799
49039
  return createFileStatePersistence({
48800
49040
  filePath: tokenCachePath(env),
48801
49041
  boundTo,
48802
- validate: (raw) => isToken(raw) ? raw : null
49042
+ validate: (raw) => isBearerTokens(raw) ? raw : null
48803
49043
  });
48804
49044
  }
48805
49045
  function reportCacheWriteFailure(err) {
@@ -48815,16 +49055,9 @@ async function captureTokenLazily() {
48815
49055
  return mod.captureTokenViaFetchproxy();
48816
49056
  }
48817
49057
  var DEFAULT_API_BASE = "https://outlook.office.com/api/v2.0";
48818
- var UNKNOWN_TOKEN_TTL_MS = 10 * 60 * 1e3;
48819
- var RECAPTURE_SENTINEL = "fetchproxy:recapture";
48820
- function toBearerTokens(accessToken) {
48821
- let expiresAt;
48822
- try {
48823
- expiresAt = decodeJwtExp(accessToken) * 1e3;
48824
- } catch {
48825
- expiresAt = Date.now() + UNKNOWN_TOKEN_TTL_MS;
48826
- }
48827
- return { accessToken, refreshToken: RECAPTURE_SENTINEL, expiresAt };
49058
+ function urlOf(input2) {
49059
+ if (typeof input2 === "string") return input2;
49060
+ return input2 instanceof URL ? input2.href : input2.url;
48828
49061
  }
48829
49062
  function isCredentialFailure(e) {
48830
49063
  const kind = errorKindOf(e);
@@ -48849,7 +49082,14 @@ var OutlookClient = class {
48849
49082
  constructor(opts = {}) {
48850
49083
  this.#env = opts.env ?? process.env;
48851
49084
  this.#capture = opts.captureToken ?? captureTokenLazily;
48852
- this.#fetchImpl = opts.fetchImpl ?? ((input2, init) => fetch(input2, init));
49085
+ const fetchImpl = opts.fetchImpl ?? ((input2, init) => fetch(input2, init));
49086
+ this.#fetchImpl = async (input2, init) => {
49087
+ try {
49088
+ return await fetchImpl(input2, init);
49089
+ } catch (e) {
49090
+ throw describeTransportFailure(e, urlOf(input2), "Outlook") ?? e;
49091
+ }
49092
+ };
48853
49093
  this.#baseUrl = readEnvVar("OUTLOOK_API_BASE", { env: this.#env }) ?? DEFAULT_API_BASE;
48854
49094
  const direct = readEnvVar("OUTLOOK_ACCESS_TOKEN", { env: this.#env });
48855
49095
  const bridgeOff = parseBoolEnv("OUTLOOK_DISABLE_FETCHPROXY", { env: this.#env });
@@ -48872,51 +49112,17 @@ var OutlookClient = class {
48872
49112
  if (this.#configError) throw this.#configError;
48873
49113
  }
48874
49114
  #tokenManager() {
48875
- if (this.#tokens) return this.#tokens;
48876
49115
  const direct = readEnvVar("OUTLOOK_ACCESS_TOKEN", { env: this.#env });
48877
- const persistence = direct === void 0 ? createTokenCache(this.#env) : null;
48878
- const mint = async () => toBearerTokens(direct ?? await this.#capture());
48879
- this.#tokens = new TokenManager({
48880
- initial: mint,
48881
- // There is no refresh grant — re-capture instead. The sentinel argument
48882
- // is ignored by design.
48883
- //
48884
- // But a token supplied as OUTLOOK_ACCESS_TOKEN must NOT be "refreshed" by
48885
- // reaching for the browser: that silently swaps the credential the
48886
- // operator chose for whichever account a tab happens to be signed into,
48887
- // and on a machine with no bridge it just hangs for the capture window on
48888
- // every 401. Say what is wrong instead.
48889
- refresh: async () => {
48890
- if (direct !== void 0) {
48891
- throw new McpToolError("OUTLOOK_ACCESS_TOKEN was rejected by Outlook.", {
48892
- hint: "A directly-supplied token cannot be refreshed automatically. Replace OUTLOOK_ACCESS_TOKEN with a current one, or unset it to capture from a signed-in Outlook browser tab instead.",
48893
- status: 401,
48894
- kind: "credential_rejected"
48895
- });
48896
- }
48897
- return toBearerTokens(await this.#capture());
48898
- },
48899
- // A failed cache write must not fail the call that triggered it: the
48900
- // token is in hand and re-capturable, so a read-only data dir should cost
48901
- // the next cold start a capture, not this request.
48902
- ...persistence ? {
48903
- persistence: {
48904
- load: () => persistence.load(),
48905
- save: (t) => {
48906
- try {
48907
- persistence.save(t);
48908
- } catch (e) {
48909
- reportCacheWriteFailure(e);
48910
- }
48911
- },
48912
- clear: () => {
48913
- try {
48914
- persistence.clear();
48915
- } catch {
48916
- }
48917
- }
48918
- }
48919
- } : {}
49116
+ this.#tokens ??= createCapturedTokenManager({
49117
+ capture: () => this.#capture(),
49118
+ direct,
49119
+ onDirectRejected: () => new McpToolError("OUTLOOK_ACCESS_TOKEN was rejected by Outlook.", {
49120
+ hint: "A directly-supplied token cannot be refreshed automatically. Replace OUTLOOK_ACCESS_TOKEN with a current one, or unset it to capture from a signed-in Outlook browser tab instead.",
49121
+ status: 401,
49122
+ kind: "credential_rejected"
49123
+ }),
49124
+ persistence: direct === void 0 ? createTokenCache(this.#env) : null,
49125
+ onPersistError: reportCacheWriteFailure
48920
49126
  });
48921
49127
  return this.#tokens;
48922
49128
  }
@@ -50098,19 +50304,14 @@ function dateTimeTimeZone2(value, zone) {
50098
50304
  return { DateTime: value, TimeZone: zone };
50099
50305
  }
50100
50306
  var TIMEOUT_SENTENCE = 'If Outlook does not answer in time (or the connection drops) the result has status "unknown": the write may well have gone through, so check before retrying \u2014 never resend blindly.';
50101
- async function writeOrUnknown(run, checkWhere) {
50102
- try {
50103
- return { ok: true, value: await run() };
50104
- } catch (e) {
50105
- if (!(e instanceof WriteOutcomeUnknownError)) throw e;
50106
- return {
50107
- ok: false,
50108
- result: minifiedResult({
50109
- status: "unknown",
50110
- warning: `Outlook did not confirm this write (${e.timedOut ? "it did not answer in time" : "the connection dropped"}), so it may already have happened. Check ${checkWhere} before retrying; do not resend blindly.`
50111
- })
50112
- };
50113
- }
50307
+ function writeOrUnknown2(run, checkWhere) {
50308
+ return writeOrUnknown(
50309
+ run,
50310
+ (e) => minifiedResult({
50311
+ status: "unknown",
50312
+ warning: `Outlook did not confirm this write (${unknownOutcomeReason(e)}), so it may already have happened. Check ${checkWhere} before retrying; do not resend blindly.`
50313
+ })
50314
+ );
50114
50315
  }
50115
50316
  var INVITE_EVENT_SELECT = "Id,Subject,IsOrganizer,IsCancelled,Start,End,Organizer,ResponseStatus";
50116
50317
  var DELETE_EVENT_SELECT = "Id,Subject,IsOrganizer,IsCancelled,ResponseStatus,Type,SeriesMasterId,Start,End,Organizer,Attendees";
@@ -50218,7 +50419,7 @@ function registerWriteTools(server, client2) {
50218
50419
  confirmToken
50219
50420
  });
50220
50421
  if (gate) return gate;
50221
- const sent = await writeOrUnknown(() => client2.write("POST", "/me/sendmail", payload), "Sent Items");
50422
+ const sent = await writeOrUnknown2(() => client2.write("POST", "/me/sendmail", payload), "Sent Items");
50222
50423
  if (!sent.ok) return sent.result;
50223
50424
  return minifiedResult({ sent: true, subject, recipients: recipients || null });
50224
50425
  }
@@ -50244,7 +50445,7 @@ function registerWriteTools(server, client2) {
50244
50445
  ToRecipients: toRecipients(to),
50245
50446
  ...cc?.length ? { CcRecipients: toRecipients(cc) } : {}
50246
50447
  };
50247
- const create = await writeOrUnknown(
50448
+ const create = await writeOrUnknown2(
50248
50449
  () => client2.write("POST", "/me/messages", payload),
50249
50450
  "Drafts"
50250
50451
  );
@@ -50440,7 +50641,7 @@ function registerWriteTools(server, client2) {
50440
50641
  confirmToken
50441
50642
  });
50442
50643
  if (gate) return gate;
50443
- const write = await writeOrUnknown(
50644
+ const write = await writeOrUnknown2(
50444
50645
  () => client2.write("POST", "/me/events", payload),
50445
50646
  "the calendar (outlook_list_events)"
50446
50647
  );
@@ -50661,7 +50862,7 @@ function registerWriteTools(server, client2) {
50661
50862
  });
50662
50863
  if (gate) return gate;
50663
50864
  }
50664
- const write = await writeOrUnknown(
50865
+ const write = await writeOrUnknown2(
50665
50866
  () => client2.write("POST", path, payload),
50666
50867
  "the event's response (outlook_get_event)"
50667
50868
  );
@@ -50744,7 +50945,7 @@ function registerWriteTools(server, client2) {
50744
50945
  });
50745
50946
  if (gate) return gate;
50746
50947
  }
50747
- const write = await writeOrUnknown(
50948
+ const write = await writeOrUnknown2(
50748
50949
  () => client2.write("DELETE", path),
50749
50950
  "the calendar (outlook_get_event)"
50750
50951
  );
@@ -50797,7 +50998,7 @@ function registerWriteTools(server, client2) {
50797
50998
  const base = `/me/messages/${encodeURIComponent(messageId)}`;
50798
50999
  const forwardTo = mode === "forward" ? { ToRecipients: toRecipients(to) } : {};
50799
51000
  if (draftOnly === true) {
50800
- const create = await writeOrUnknown(
51001
+ const create = await writeOrUnknown2(
50801
51002
  () => client2.write(
50802
51003
  "POST",
50803
51004
  `${base}/${draft}`,
@@ -50864,7 +51065,7 @@ function registerWriteTools(server, client2) {
50864
51065
  confirmToken
50865
51066
  });
50866
51067
  if (gate) return gate;
50867
- const sent = await writeOrUnknown(() => client2.write("POST", path, payload), "Sent Items");
51068
+ const sent = await writeOrUnknown2(() => client2.write("POST", path, payload), "Sent Items");
50868
51069
  if (!sent.ok) return sent.result;
50869
51070
  return mailboxUntrusted({
50870
51071
  sent: true,
package/dist/client.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * held, every request is a plain Node `fetch` — the bridge is never on the hot
7
7
  * path.
8
8
  */
9
- import { createApiClient, decodeJwtExp, errorKindOf, errorStatusOf, McpToolError, parseBoolEnv, readEnvVar, } from '@chrischall/mcp-utils';
10
- import { TokenManager } from '@chrischall/mcp-utils/session';
9
+ import { createApiClient, describeTransportFailure, errorKindOf, errorStatusOf, McpToolError, parseBoolEnv, readEnvVar, } from '@chrischall/mcp-utils';
10
+ import { createCapturedTokenManager } from '@chrischall/mcp-utils/session';
11
11
  import { createTokenCache, reportCacheWriteFailure } from './token-cache.js';
12
12
  /**
13
13
  * Load the bridge bootstrap lazily.
@@ -34,35 +34,11 @@ async function captureTokenLazily() {
34
34
  * is the host that answers. Verified live 2026-09-20.
35
35
  */
36
36
  export const DEFAULT_API_BASE = 'https://outlook.office.com/api/v2.0';
37
- /**
38
- * Fallback lifetime for a token whose `exp` we cannot read.
39
- *
40
- * Real Outlook tokens are JWTs carrying `exp` (~25h out), so this is only
41
- * reached for a hand-supplied opaque `OUTLOOK_ACCESS_TOKEN`. Deliberately
42
- * short: being wrong in this direction costs one extra 401-and-replay, while
43
- * being wrong the other way makes every call fail until a restart.
44
- */
45
- const UNKNOWN_TOKEN_TTL_MS = 10 * 60 * 1000;
46
- /**
47
- * Sentinel standing in for a refresh token this flow does not have.
48
- *
49
- * Outlook Web's token comes from MSAL's hidden-iframe flow; there is no refresh
50
- * grant we can drive. "Refreshing" therefore means capturing a new one. The
51
- * sentinel exists because `TokenManager` only calls `refresh` when a refresh
52
- * token is present, and without it an expired token would have no path back
53
- * short of restarting the process.
54
- */
55
- const RECAPTURE_SENTINEL = 'fetchproxy:recapture';
56
- function toBearerTokens(accessToken) {
57
- let expiresAt;
58
- try {
59
- // decodeJwtExp returns seconds; a non-JWT throws.
60
- expiresAt = decodeJwtExp(accessToken) * 1000;
61
- }
62
- catch {
63
- expiresAt = Date.now() + UNKNOWN_TOKEN_TTL_MS;
64
- }
65
- return { accessToken, refreshToken: RECAPTURE_SENTINEL, expiresAt };
37
+ /** The URL a `fetch` was called with, for naming its host in a transport failure. */
38
+ function urlOf(input) {
39
+ if (typeof input === 'string')
40
+ return input;
41
+ return input instanceof URL ? input.href : input.url;
66
42
  }
67
43
  /**
68
44
  * Whether `e` is about the credential rather than the request: no token
@@ -97,7 +73,20 @@ export class OutlookClient {
97
73
  // and calling it as `this.#fetchImpl(...)` binds `this` to the client, and
98
74
  // older undici (Node 18-20, which several MCP hosts still bundle) throws
99
75
  // `Illegal invocation` for any receiver that is not `globalThis`.
100
- this.#fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
76
+ const fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
77
+ // undici reports every transport failure (DNS, refused/reset connection,
78
+ // untrusted TLS, a proxy refusing the tunnel) as a bare `TypeError('fetch
79
+ // failed')`; name the host, the cause code and what to check instead. The
80
+ // client still marks it a transport failure, so a sent write whose
81
+ // connection drops stays outcome-unknown.
82
+ this.#fetchImpl = async (input, init) => {
83
+ try {
84
+ return await fetchImpl(input, init);
85
+ }
86
+ catch (e) {
87
+ throw describeTransportFailure(e, urlOf(input), 'Outlook') ?? e;
88
+ }
89
+ };
101
90
  this.#baseUrl = readEnvVar('OUTLOOK_API_BASE', { env: this.#env }) ?? DEFAULT_API_BASE;
102
91
  const direct = readEnvVar('OUTLOOK_ACCESS_TOKEN', { env: this.#env });
103
92
  const bridgeOff = parseBoolEnv('OUTLOOK_DISABLE_FETCHPROXY', { env: this.#env });
@@ -119,59 +108,31 @@ export class OutlookClient {
119
108
  throw this.#configError;
120
109
  }
121
110
  #tokenManager() {
122
- if (this.#tokens)
123
- return this.#tokens;
124
111
  const direct = readEnvVar('OUTLOOK_ACCESS_TOKEN', { env: this.#env });
125
- const persistence = direct === undefined ? createTokenCache(this.#env) : null;
126
- const mint = async () => toBearerTokens(direct ?? (await this.#capture()));
127
- this.#tokens = new TokenManager({
128
- initial: mint,
129
- // There is no refresh grant — re-capture instead. The sentinel argument
130
- // is ignored by design.
131
- //
132
- // But a token supplied as OUTLOOK_ACCESS_TOKEN must NOT be "refreshed" by
133
- // reaching for the browser: that silently swaps the credential the
134
- // operator chose for whichever account a tab happens to be signed into,
135
- // and on a machine with no bridge it just hangs for the capture window on
136
- // every 401. Say what is wrong instead.
137
- refresh: async () => {
138
- if (direct !== undefined) {
139
- throw new McpToolError('OUTLOOK_ACCESS_TOKEN was rejected by Outlook.', {
140
- hint: 'A directly-supplied token cannot be refreshed automatically. Replace ' +
141
- 'OUTLOOK_ACCESS_TOKEN with a current one, or unset it to capture from a ' +
142
- 'signed-in Outlook browser tab instead.',
143
- status: 401,
144
- kind: 'credential_rejected',
145
- });
146
- }
147
- return toBearerTokens(await this.#capture());
148
- },
149
- // A failed cache write must not fail the call that triggered it: the
150
- // token is in hand and re-capturable, so a read-only data dir should cost
151
- // the next cold start a capture, not this request.
152
- ...(persistence
153
- ? {
154
- persistence: {
155
- load: () => persistence.load(),
156
- save: (t) => {
157
- try {
158
- persistence.save(t);
159
- }
160
- catch (e) {
161
- reportCacheWriteFailure(e);
162
- }
163
- },
164
- clear: () => {
165
- try {
166
- persistence.clear();
167
- }
168
- catch {
169
- /* nothing useful to do; the next capture overwrites it */
170
- }
171
- },
172
- },
173
- }
174
- : {}),
112
+ // Captures on first use (or loads a still-valid cached capture); there is
113
+ // no refresh grant, so a 401 or an expiring token re-captures instead.
114
+ //
115
+ // But a token supplied as OUTLOOK_ACCESS_TOKEN is never cached and never
116
+ // "refreshed" by reaching for the browser: that silently swaps the
117
+ // credential the operator chose for whichever account a tab happens to be
118
+ // signed into, and on a machine with no bridge it just hangs for the
119
+ // capture window on every 401. Say what is wrong instead.
120
+ //
121
+ // A failed cache write must not fail the call that triggered it: the
122
+ // token is in hand and re-capturable, so a read-only data dir should cost
123
+ // the next cold start a capture, not this request.
124
+ this.#tokens ??= createCapturedTokenManager({
125
+ capture: () => this.#capture(),
126
+ direct,
127
+ onDirectRejected: () => new McpToolError('OUTLOOK_ACCESS_TOKEN was rejected by Outlook.', {
128
+ hint: 'A directly-supplied token cannot be refreshed automatically. Replace ' +
129
+ 'OUTLOOK_ACCESS_TOKEN with a current one, or unset it to capture from a ' +
130
+ 'signed-in Outlook browser tab instead.',
131
+ status: 401,
132
+ kind: 'credential_rejected',
133
+ }),
134
+ persistence: direct === undefined ? createTokenCache(this.#env) : null,
135
+ onPersistError: reportCacheWriteFailure,
175
136
  });
176
137
  return this.#tokens;
177
138
  }
@@ -1,4 +1,4 @@
1
- import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
1
+ import { createFileStatePersistence, isBearerTokens, resolveStateFile, } from '@chrischall/mcp-utils/session';
2
2
  import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
3
3
  /**
4
4
  * Where the captured Outlook access token is cached between runs.
@@ -15,15 +15,6 @@ export function tokenCachePath(env = process.env) {
15
15
  fileName: 'token.json',
16
16
  });
17
17
  }
18
- function isToken(raw) {
19
- if (raw === null || typeof raw !== 'object')
20
- return false;
21
- const t = raw;
22
- return (typeof t.accessToken === 'string' &&
23
- t.accessToken !== '' &&
24
- typeof t.expiresAt === 'number' &&
25
- (t.refreshToken === undefined || typeof t.refreshToken === 'string'));
26
- }
27
18
  /**
28
19
  * What a cached token is bound to, or `null` when there is nothing worth
29
20
  * caching.
@@ -51,7 +42,7 @@ export function createTokenCache(env = process.env) {
51
42
  return createFileStatePersistence({
52
43
  filePath: tokenCachePath(env),
53
44
  boundTo,
54
- validate: (raw) => (isToken(raw) ? raw : null),
45
+ validate: (raw) => (isBearerTokens(raw) ? raw : null),
55
46
  });
56
47
  }
57
48
  /**
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, errorKindOf, errorStatusOf, mapWithConcurrency, McpToolError, minifiedResult, WriteOutcomeUnknownError, } from '@chrischall/mcp-utils';
2
+ import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, errorKindOf, errorStatusOf, mapWithConcurrency, McpToolError, minifiedResult, unknownOutcomeReason, writeOrUnknown as runWriteOrUnknown, WriteOutcomeUnknownError, } from '@chrischall/mcp-utils';
3
3
  import { isCredentialFailure } from '../client.js';
4
4
  import { mailboxTimeZone } from '../timezone.js';
5
5
  import { cappedRecipients, MAX_RECIPIENTS } from '../view.js';
@@ -80,21 +80,11 @@ const TIMEOUT_SENTENCE = 'If Outlook does not answer in time (or the connection
80
80
  * send a second identical email. Any other failure is a real failure and
81
81
  * still throws.
82
82
  */
83
- async function writeOrUnknown(run, checkWhere) {
84
- try {
85
- return { ok: true, value: await run() };
86
- }
87
- catch (e) {
88
- if (!(e instanceof WriteOutcomeUnknownError))
89
- throw e;
90
- return {
91
- ok: false,
92
- result: minifiedResult({
93
- status: 'unknown',
94
- warning: `Outlook did not confirm this write (${e.timedOut ? 'it did not answer in time' : 'the connection dropped'}), so it may already have happened. Check ${checkWhere} before retrying; do not resend blindly.`,
95
- }),
96
- };
97
- }
83
+ function writeOrUnknown(run, checkWhere) {
84
+ return runWriteOrUnknown(run, (e) => minifiedResult({
85
+ status: 'unknown',
86
+ warning: `Outlook did not confirm this write (${unknownOutcomeReason(e)}), so it may already have happened. Check ${checkWhere} before retrying; do not resend blindly.`,
87
+ }));
98
88
  }
99
89
  const INVITE_EVENT_SELECT = 'Id,Subject,IsOrganizer,IsCancelled,Start,End,Organizer,ResponseStatus';
100
90
  const DELETE_EVENT_SELECT = 'Id,Subject,IsOrganizer,IsCancelled,ResponseStatus,Type,SeriesMasterId,Start,End,Organizer,Attendees';
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.6.1'; // x-release-please-version
13
+ export const VERSION = '0.6.2'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.6.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>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.check.json"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^3.0.3",
48
+ "@chrischall/mcp-utils": "^3.1.0",
49
49
  "@fetchproxy/server": "^3.6.2",
50
50
  "@modelcontextprotocol/server": "^2.3.0",
51
51
  "dotenv": "^18.0.1",
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.6.1",
9
+ "version": "0.6.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.6.1",
14
+ "version": "0.6.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },