@chrischall/office-outlook-mcp 0.6.0 → 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.
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';
@@ -31,6 +31,14 @@ const teamsMeetingParam = z
31
31
  .boolean()
32
32
  .optional()
33
33
  .describe('Attach a Microsoft Teams meeting (default true). false leaves it off.');
34
+ /**
35
+ * On update the Teams meeting is opt-in: an edit to an in-person meeting (or one
36
+ * on another provider) must not change how people join it unless asked.
37
+ */
38
+ const addTeamsMeetingParam = z
39
+ .boolean()
40
+ .optional()
41
+ .describe('true adds a Microsoft Teams meeting to an event that lacks one (default false: the way people join is left as it is).');
34
42
  const EVENT_STATE_SELECT = 'Id,Subject,IsOrganizer,Start,End,Location,Attendees,IsOnlineMeeting,OnlineMeetingProvider,OnlineMeeting';
35
43
  function attendee(Address, Type) {
36
44
  return { Type, EmailAddress: { Address } };
@@ -72,21 +80,11 @@ const TIMEOUT_SENTENCE = 'If Outlook does not answer in time (or the connection
72
80
  * send a second identical email. Any other failure is a real failure and
73
81
  * still throws.
74
82
  */
75
- async function writeOrUnknown(run, checkWhere) {
76
- try {
77
- return { ok: true, value: await run() };
78
- }
79
- catch (e) {
80
- if (!(e instanceof WriteOutcomeUnknownError))
81
- throw e;
82
- return {
83
- ok: false,
84
- result: minifiedResult({
85
- status: 'unknown',
86
- 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.`,
87
- }),
88
- };
89
- }
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
+ }));
90
88
  }
91
89
  const INVITE_EVENT_SELECT = 'Id,Subject,IsOrganizer,IsCancelled,Start,End,Organizer,ResponseStatus';
92
90
  const DELETE_EVENT_SELECT = 'Id,Subject,IsOrganizer,IsCancelled,ResponseStatus,Type,SeriesMasterId,Start,End,Organizer,Attendees';
@@ -242,37 +240,32 @@ export function registerWriteTools(server, client) {
242
240
  return minifiedResult({ sent: true, subject, recipients: recipients || null });
243
241
  });
244
242
  server.registerTool('outlook_create_draft', {
245
- description: 'Create a draft message in the Drafts folder without sending it. Returns the created draft, which can be reviewed and sent from Outlook.' +
246
- ' ' +
247
- CONFIRM_FLOW_SENTENCE,
248
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
243
+ description: 'Create a draft message in the Drafts folder without sending it. Returns the created draft, which can be reviewed and sent from Outlook. ' +
244
+ 'It does not ask to confirm: a draft reaches nobody until it is sent from Outlook. Calling it twice leaves two drafts. ' +
245
+ TIMEOUT_SENTENCE,
246
+ // Self-only and additive, like outlook_reply with draftOnly: nothing is
247
+ // overwritten or sent. Not idempotent: a repeat creates a second draft.
248
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
249
249
  inputSchema: z.object({
250
250
  to: recipientList,
251
251
  cc: recipientList,
252
252
  subject: z.string().describe('Subject line'),
253
253
  body: z.string().describe('Message body'),
254
254
  html: z.boolean().optional().describe('Compose the body as HTML (default: plain text)'),
255
- confirmToken: confirmTokenParam,
256
255
  }),
257
- }, async ({ to, cc, subject, body, html, confirmToken }, ctx) => {
256
+ }, async ({ to, cc, subject, body, html }) => {
258
257
  const payload = {
259
258
  ...messageBody(subject, body, html),
260
259
  ToRecipients: toRecipients(to),
261
260
  ...(cc?.length ? { CcRecipients: toRecipients(cc) } : {}),
262
261
  };
263
- const gate = await confirmWrite(ctx, {
264
- tool: 'outlook_create_draft',
265
- action: 'mail.create_draft',
266
- message: 'Review and confirm this draft:',
267
- summary: `Create draft "${subject}"`,
268
- // One signed-in mailbox per server process.
269
- account: undefined,
270
- request: { method: 'POST', path: '/me/messages', body: payload },
271
- confirmToken,
272
- });
273
- if (gate)
274
- return gate;
275
- const created = await client.write('POST', '/me/messages', payload);
262
+ // Nothing leaves the mailbox, so there is no one to protect with a
263
+ // confirmation (same rule as outlook_reply draftOnly). A lost answer
264
+ // says to look in Drafts before retrying, so a retry does not duplicate.
265
+ const create = await writeOrUnknown(() => client.write('POST', '/me/messages', payload), 'Drafts');
266
+ if (!create.ok)
267
+ return create.result;
268
+ const created = create.value;
276
269
  return minifiedResult({ created: true, Id: created?.Id, WebLink: created?.WebLink });
277
270
  });
278
271
  server.registerTool('outlook_mark_read', {
@@ -507,7 +500,7 @@ export function registerWriteTools(server, client) {
507
500
  });
508
501
  });
509
502
  server.registerTool('outlook_update_event', {
510
- description: 'Update a meeting you organize: retitle, move, relocate, edit the description, or add/remove attendees. Moving only `start` keeps the meeting\'s length. A Microsoft Teams meeting is added if the event lacks one (`teamsMeeting: false` to skip). Attendees are sent an updated invitation. Only the organizer can update a meeting — an attendee\'s edit would change their own copy alone, so it is refused. The result is verified by re-reading the event.' +
503
+ description: 'Update a meeting you organize: retitle, move, relocate, edit the description, or add/remove attendees. Moving only `start` keeps the meeting\'s length. It does not add a Microsoft Teams meeting unless you pass `teamsMeeting: true` (on an event that lacks one). Attendees are sent an updated invitation. Only the organizer can update a meeting — an attendee\'s edit would change their own copy alone, so it is refused. The result is verified by re-reading the event.' +
511
504
  ' ' +
512
505
  CONFIRM_FLOW_SENTENCE +
513
506
  ' ' +
@@ -530,7 +523,7 @@ export function registerWriteTools(server, client) {
530
523
  addAttendees: recipientList.describe('Required attendees to add'),
531
524
  addOptionalAttendees: recipientList.describe('Optional attendees to add'),
532
525
  removeAttendees: recipientList.describe('Attendees to remove'),
533
- teamsMeeting: teamsMeetingParam,
526
+ teamsMeeting: addTeamsMeetingParam,
534
527
  confirmToken: confirmTokenParam,
535
528
  }),
536
529
  }, async (args, ctx) => {
@@ -603,19 +596,26 @@ export function registerWriteTools(server, client) {
603
596
  }
604
597
  payload.Attendees = next;
605
598
  }
606
- const addTeams = args.teamsMeeting !== false && !(current && isTeams(current));
599
+ const addTeams = args.teamsMeeting === true && !(current && isTeams(current));
607
600
  if (addTeams)
608
601
  Object.assign(payload, TEAMS);
609
602
  if (Object.keys(payload).length === 0) {
610
603
  throw new McpToolError('Nothing to update.', {
611
- hint: 'Pass at least one of subject, start, end, location, body, or attendee changes.',
604
+ hint: 'Pass at least one of subject, start, end, location, body, attendee changes, or teamsMeeting: true.',
612
605
  });
613
606
  }
614
607
  const gate = await confirmWrite(ctx, {
615
608
  tool: 'outlook_update_event',
616
609
  action: 'calendar.update_event',
617
- message: 'Review and confirm this change (attendees are sent an updated invitation):',
618
- summary: `Update "${current?.Subject ?? args.id}": ${Object.keys(payload).join(', ')}`,
610
+ message: addTeams
611
+ ? 'Review and confirm this change (attendees are sent an updated invitation). This will also make it a Microsoft Teams meeting: attendees get a Teams join link.'
612
+ : 'Review and confirm this change (attendees are sent an updated invitation):',
613
+ summary: `Update "${current?.Subject ?? args.id}": ${[
614
+ Object.keys(payload).filter((k) => !(k in TEAMS)).join(', '),
615
+ addTeams ? 'this will also make it a Microsoft Teams meeting' : '',
616
+ ]
617
+ .filter((part) => part !== '')
618
+ .join('; ')}`,
619
619
  // One signed-in mailbox per server process.
620
620
  account: undefined,
621
621
  target: args.id,
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.0'; // 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.0",
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,8 +45,8 @@
45
45
  "typecheck": "tsc -p tsconfig.check.json"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^3.0.1",
49
- "@fetchproxy/server": "^3.6.0",
48
+ "@chrischall/mcp-utils": "^3.1.0",
49
+ "@fetchproxy/server": "^3.6.2",
50
50
  "@modelcontextprotocol/server": "^2.3.0",
51
51
  "dotenv": "^18.0.1",
52
52
  "zod": "^4.6.5"
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.0",
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.0",
14
+ "version": "0.6.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },