@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
|
@@ -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.
|
|
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.
|
|
18
|
+
"version": "0.6.2",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/.claude-plugin/mcp.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "office-outlook-mcp",
|
|
3
3
|
"displayName": "Outlook (Microsoft 365)",
|
|
4
|
-
"version": "0.6.
|
|
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",
|
package/README.md
CHANGED
|
@@ -119,8 +119,9 @@ Everything is optional — with nothing set, the server captures from the browse
|
|
|
119
119
|
`outlook_update_message`, `outlook_move_message`, `outlook_create_event`,
|
|
120
120
|
`outlook_update_event`, `outlook_respond_to_invite`, `outlook_delete_event`,
|
|
121
121
|
`outlook_reply`.
|
|
122
|
-
|
|
123
|
-
`
|
|
122
|
+
Some skip the confirmation because nothing reaches anyone else:
|
|
123
|
+
`outlook_create_draft` (the draft stays in Drafts until you send it from
|
|
124
|
+
Outlook), `outlook_reply` with `draftOnly: true` (the reply stays in Drafts),
|
|
124
125
|
`outlook_respond_to_invite` with `sendResponse: false`, and
|
|
125
126
|
`outlook_delete_event` on your own appointment with no attendees or on a
|
|
126
127
|
meeting the organizer already cancelled (in each, only your calendar
|
|
@@ -129,9 +130,12 @@ changes).
|
|
|
129
130
|
**Meetings** — `outlook_find_meeting_times` asks Outlook's Scheduling
|
|
130
131
|
Assistant for slots when everyone is free, and `outlook_get_schedule` shows
|
|
131
132
|
each person's busy blocks. Each suggested slot carries `createEventArgs`,
|
|
132
|
-
which `outlook_create_event` accepts unchanged. `outlook_create_event`
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
which `outlook_create_event` accepts unchanged. `outlook_create_event`
|
|
134
|
+
attaches a Microsoft Teams meeting by default and returns its join link; pass
|
|
135
|
+
`teamsMeeting: false` to leave it off. `outlook_update_event` never changes how
|
|
136
|
+
people join a meeting unless asked: pass `teamsMeeting: true` to add a Teams
|
|
137
|
+
meeting to an event that lacks one, and the confirmation says so in plain
|
|
138
|
+
words. Only the organizer
|
|
135
139
|
can update a meeting. `outlook_respond_to_invite` accepts, tentatively accepts
|
|
136
140
|
or declines an invite, by its message id or its event id.
|
|
137
141
|
`outlook_delete_event` deletes an event you organize — one occurrence by the
|
|
@@ -217,7 +221,8 @@ or repeated across pages, and marking mail read between pages loses nothing.
|
|
|
217
221
|
## Confirmations
|
|
218
222
|
|
|
219
223
|
Every write asks you to confirm before anything is sent or changed (except a
|
|
220
|
-
draft-only reply and a silent invite response — see
|
|
224
|
+
new draft, a draft-only reply and a silent invite response — see
|
|
225
|
+
[Tools](#tools)). A client
|
|
221
226
|
that can show a confirmation prompt (Claude Code) shows one. On a client that
|
|
222
227
|
cannot, the first call does nothing and returns a `confirmation-required`
|
|
223
228
|
preview plus a `confirmToken`; only a repeat call with that token performs the
|
package/dist/auth-fetchproxy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
}
|