@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/mcp.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -6
- package/dist/auth-fetchproxy.js +26 -122
- package/dist/bundle.js +603 -259
- package/dist/client.js +45 -84
- package/dist/token-cache.js +2 -11
- package/dist/tools/writes.js +41 -41
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
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,
|
|
10
|
-
import {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|
package/dist/token-cache.js
CHANGED
|
@@ -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) => (
|
|
45
|
+
validate: (raw) => (isBearerTokens(raw) ? raw : null),
|
|
55
46
|
});
|
|
56
47
|
}
|
|
57
48
|
/**
|
package/dist/tools/writes.js
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
618
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/office-outlook-mcp",
|
|
3
|
-
"version": "0.6.
|
|
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
|
|
49
|
-
"@fetchproxy/server": "^3.6.
|
|
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.
|
|
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.
|
|
14
|
+
"version": "0.6.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|