@chrischall/office-outlook-mcp 0.0.0
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 +27 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.mcp.json +11 -0
- package/README.md +106 -0
- package/dist/auth-fetchproxy.js +166 -0
- package/dist/bundle.js +46233 -0
- package/dist/client.js +247 -0
- package/dist/index.js +36 -0
- package/dist/token-cache.js +68 -0
- package/dist/tools/_confirm.js +27 -0
- package/dist/tools/calendar.js +72 -0
- package/dist/tools/directory.js +73 -0
- package/dist/tools/healthcheck.js +45 -0
- package/dist/tools/mail.js +130 -0
- package/dist/tools/writes.js +165 -0
- package/dist/version.js +13 -0
- package/dist/view.js +109 -0
- package/mint.yaml +79 -0
- package/package.json +62 -0
- package/server.json +63 -0
- package/skills/outlook-fpx/SKILL.md +82 -0
- package/skills/outlook-fpx/references/api-recipes.md +163 -0
- package/skills/outlook-fpx/references/outlook-env.sh +159 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
|
+
"name": "office-outlook-mcp",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "Chris Hall",
|
|
6
|
+
"email": "chris.c.hall@gmail.com"
|
|
7
|
+
},
|
|
8
|
+
"metadata": {
|
|
9
|
+
"description": "MCP server for Outlook / Microsoft 365 — mail, calendar, contacts and tasks via natural language",
|
|
10
|
+
"version": "0.0.0"
|
|
11
|
+
},
|
|
12
|
+
"plugins": [
|
|
13
|
+
{
|
|
14
|
+
"name": "office-outlook-mcp",
|
|
15
|
+
"displayName": "Outlook (Microsoft 365)",
|
|
16
|
+
"source": "./",
|
|
17
|
+
"description": "MCP server for Outlook / Microsoft 365 — read mail, folders, calendar, contacts and tasks, and send mail with confirmation",
|
|
18
|
+
"version": "0.0.0",
|
|
19
|
+
"author": { "name": "Chris Hall" },
|
|
20
|
+
"homepage": "https://github.com/chrischall/office-outlook-mcp",
|
|
21
|
+
"repository": "https://github.com/chrischall/office-outlook-mcp",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"keywords": ["outlook", "office365", "microsoft365", "email", "calendar", "mcp"],
|
|
24
|
+
"category": "productivity"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "office-outlook-mcp",
|
|
3
|
+
"displayName": "Outlook (Microsoft 365)",
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"description": "Outlook / Microsoft 365 for Claude — read mail, folders, calendar, contacts and tasks, and send mail with confirmation, using your signed-in browser session",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Chris Hall",
|
|
8
|
+
"email": "chris.c.hall@gmail.com"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/chrischall/office-outlook-mcp",
|
|
11
|
+
"repository": "https://github.com/chrischall/office-outlook-mcp",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": ["outlook", "office365", "microsoft365", "email", "calendar", "mcp"],
|
|
14
|
+
"skills": "./skills/",
|
|
15
|
+
"mcp": "./.mcp.json"
|
|
16
|
+
}
|
package/.mcp.json
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# office-outlook-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for **Outlook / Microsoft 365** — mail, folders, calendar, contacts
|
|
4
|
+
and tasks, with confirm-gated sending.
|
|
5
|
+
|
|
6
|
+
> This project was developed and is maintained by AI (Claude Code). Use at your
|
|
7
|
+
> own discretion.
|
|
8
|
+
|
|
9
|
+
## How it authenticates
|
|
10
|
+
|
|
11
|
+
Outlook Web holds its access token in memory and sends it as
|
|
12
|
+
`Authorization: Bearer …`. This server snapshots that header from your
|
|
13
|
+
signed-in browser tab through the [fetchproxy](https://github.com/chrischall/fetchproxy)
|
|
14
|
+
bridge, then talks to `https://outlook.office.com/api/v2.0` with ordinary
|
|
15
|
+
server-side requests.
|
|
16
|
+
|
|
17
|
+
**The browser is needed only to mint the token (~25h), never to make a call.**
|
|
18
|
+
That is the whole design: no Azure app registration, no admin consent, and no
|
|
19
|
+
browser on the request path.
|
|
20
|
+
|
|
21
|
+
Tokens are cached at `~/.office-outlook-mcp/token.json` (0600), so a restart
|
|
22
|
+
does not re-capture.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm i -g @chrischall/office-outlook-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires the **Transporter** Chrome extension and `@fetchproxy/cli`, on a
|
|
31
|
+
matching major version:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm i -g @fetchproxy/cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The first capture prints a 6-digit pair code to approve in the Transporter
|
|
38
|
+
popup; the grant persists.
|
|
39
|
+
|
|
40
|
+
### Configuration
|
|
41
|
+
|
|
42
|
+
Everything is optional — with nothing set, the server captures from the browser.
|
|
43
|
+
|
|
44
|
+
| variable | purpose |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `OUTLOOK_ACCESS_TOKEN` | Pre-obtained bearer token. Overrides the bridge. Power users / CI only; expires in ~25h and **cannot be auto-refreshed**. |
|
|
47
|
+
| `OUTLOOK_DISABLE_FETCHPROXY` | `1` to disable browser capture, requiring `OUTLOOK_ACCESS_TOKEN`. |
|
|
48
|
+
| `OUTLOOK_API_BASE` | Override the REST base URL. Defaults to `https://outlook.office.com/api/v2.0`. |
|
|
49
|
+
| `OUTLOOK_WS_PORT` | fetchproxy concentrator port. Defaults to `37149`, the fleet-wide shared port. |
|
|
50
|
+
| `OUTLOOK_CAPTURE_TIMEOUT` | Seconds to wait for the tab to make a readable request. Defaults to `30`. |
|
|
51
|
+
| `OUTLOOK_TOKEN_CACHE` | `false` to stop caching the token between runs. |
|
|
52
|
+
| `OUTLOOK_TOKEN_FILE` | Path to the cached token. Defaults to `~/.office-outlook-mcp/token.json`. |
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
**Read** — `outlook_list_folders`, `outlook_list_messages`,
|
|
57
|
+
`outlook_get_message`, `outlook_list_attachments`, `outlook_list_events`,
|
|
58
|
+
`outlook_get_event`, `outlook_list_calendars`, `outlook_get_profile`,
|
|
59
|
+
`outlook_get_mailbox_settings`, `outlook_list_contacts`, `outlook_list_people`,
|
|
60
|
+
`outlook_list_tasks`
|
|
61
|
+
|
|
62
|
+
**Write** (all require `confirm: true`) — `outlook_send_mail`,
|
|
63
|
+
`outlook_create_draft`, `outlook_mark_read`, `outlook_move_message`,
|
|
64
|
+
`outlook_create_event`
|
|
65
|
+
|
|
66
|
+
**Diagnostics** — `outlook_healthcheck`
|
|
67
|
+
|
|
68
|
+
Every read tool takes `view: compact | full | raw`, defaulting to **compact**.
|
|
69
|
+
Mutating tools make **no network call** without `confirm: true` — they return a
|
|
70
|
+
dry-run preview of exactly what would be sent.
|
|
71
|
+
|
|
72
|
+
## Things worth knowing
|
|
73
|
+
|
|
74
|
+
- Fields are **PascalCase** (`ReceivedDateTime`, `IsRead`). This is the Outlook
|
|
75
|
+
REST API, not Graph — a Graph snippet silently returns nothing.
|
|
76
|
+
- `outlook_get_message` returns a **plain-text** body by default, measured 9.4×
|
|
77
|
+
smaller than the HTML; pass `html: true` for the original.
|
|
78
|
+
- `outlook_list_events` uses the calendar *view*, which expands recurring
|
|
79
|
+
series. `/me/events` does not, which is why it is not exposed as-is.
|
|
80
|
+
- Time zones are **Windows** names (`Eastern Standard Time`), not IANA.
|
|
81
|
+
- `search` and `unreadOnly` cannot be combined; the API rejects `$search`
|
|
82
|
+
alongside `$filter`, so the tool refuses before making a doomed request.
|
|
83
|
+
- Moving a message assigns it a **new id**.
|
|
84
|
+
|
|
85
|
+
## The lightweight alternative
|
|
86
|
+
|
|
87
|
+
If you only need Outlook access from Claude Code on this machine, the
|
|
88
|
+
`skills/outlook-fpx` access skill in this repo does the same reads with `fpx` +
|
|
89
|
+
`curl` and no server at all. The MCP earns its keep when you want typed tools,
|
|
90
|
+
confirm-gated writes, or reach from claude.ai.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm install
|
|
96
|
+
npm run build
|
|
97
|
+
npm test # typecheck + suite
|
|
98
|
+
npm run test:coverage # CI's gate
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Request shapes are pinned in [`docs/OUTLOOK-API.md`](docs/OUTLOOK-API.md), all
|
|
102
|
+
verified against a live mailbox.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap-only fetchproxy auth.
|
|
3
|
+
*
|
|
4
|
+
* Outlook Web holds its access token in JS memory and sends it as
|
|
5
|
+
* `Authorization: Bearer …` on every API call. We snapshot that header off a
|
|
6
|
+
* request the page already makes, and from then on this MCP talks to
|
|
7
|
+
* `https://outlook.office.com/api/v2.0` with a plain Node `fetch`.
|
|
8
|
+
*
|
|
9
|
+
* The bridge is therefore touched ONCE per token (~25h), never in the hot path.
|
|
10
|
+
* That is what makes this repo hostable: a full-bridge MCP needs a browser
|
|
11
|
+
* attached for every call, this one needs it only to mint.
|
|
12
|
+
*
|
|
13
|
+
* Verified live 2026-09-20 against a real M365 mailbox:
|
|
14
|
+
* - the signed-in tab is on `outlook.cloud.microsoft` (the new Outlook Web
|
|
15
|
+
* domain), but the token it carries has `aud: https://outlook.office.com`;
|
|
16
|
+
* - that token authenticates plain server-side requests to
|
|
17
|
+
* `outlook.office.com/api/v2.0` with no bridge involved;
|
|
18
|
+
* - older tenants still serve the app from `outlook.office.com`, so both
|
|
19
|
+
* hosts are declared and captured concurrently.
|
|
20
|
+
*
|
|
21
|
+
* We do NOT try to mint a token by calling an endpoint ourselves. There is no
|
|
22
|
+
* such endpoint reachable from the page's origin — the token comes from MSAL's
|
|
23
|
+
* hidden-iframe flow against login.microsoftonline.com, which a bridge fetch
|
|
24
|
+
* cannot drive. Capture is the only path, which is why a failed capture is a
|
|
25
|
+
* hard error with actionable guidance rather than a fallback.
|
|
26
|
+
*/
|
|
27
|
+
import { createBootstrapOpts, createFetchproxyTransport, } from '@chrischall/mcp-utils/fetchproxy';
|
|
28
|
+
import { readPortEnv, readTtlMsEnv } from '@chrischall/mcp-utils';
|
|
29
|
+
import { PACKAGE_NAME, VERSION } from './version.js';
|
|
30
|
+
/**
|
|
31
|
+
* The fetchproxy concentrator port.
|
|
32
|
+
*
|
|
33
|
+
* ONE port for the whole fleet: the Transporter extension dials this port and
|
|
34
|
+
* servers host/peer-elect on it, so a "unique" per-MCP default would simply
|
|
35
|
+
* never be found. `OUTLOOK_WS_PORT` overrides it for local development and —
|
|
36
|
+
* the reason it exists — for a hosted bridged registration, where mcp-host
|
|
37
|
+
* names this variable in `bridgePortEnv` and puts the child's port in it.
|
|
38
|
+
*
|
|
39
|
+
* `readPortEnv` rather than `Number(...)` because the value arrives from a host
|
|
40
|
+
* template: an unsubstituted `${OUTLOOK_WS_PORT}` falls back to the default
|
|
41
|
+
* instead of handing `NaN` to the server.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_WS_PORT = 37_149;
|
|
44
|
+
export function getWsPort() {
|
|
45
|
+
return readPortEnv('OUTLOOK_WS_PORT', DEFAULT_WS_PORT);
|
|
46
|
+
}
|
|
47
|
+
/** New Outlook Web. This is where a current tenant's tab actually lives. */
|
|
48
|
+
const CAPTURE_DECL_CLOUD = {
|
|
49
|
+
host: 'outlook.cloud.microsoft',
|
|
50
|
+
path: '/*',
|
|
51
|
+
headerName: 'authorization',
|
|
52
|
+
};
|
|
53
|
+
/** Legacy host, still serving the app for tenants not yet migrated. */
|
|
54
|
+
const CAPTURE_DECL_OFFICE = {
|
|
55
|
+
host: 'outlook.office.com',
|
|
56
|
+
path: '/*',
|
|
57
|
+
headerName: 'authorization',
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* How long to wait for the page to make a request we can read.
|
|
61
|
+
*
|
|
62
|
+
* The window IS the mechanism: capture resolves on the NEXT matching request,
|
|
63
|
+
* so nothing the page did before the listener came up counts. Outlook Web polls
|
|
64
|
+
* on its own, but an idle background tab can stay quiet for a while — 30s is
|
|
65
|
+
* long enough that ordinary polling or a user glance lands inside it, short
|
|
66
|
+
* enough that an idle tab does not hang a tool call for minutes.
|
|
67
|
+
*/
|
|
68
|
+
const CAPTURE_TIMEOUT_MS = readTtlMsEnv('OUTLOOK_CAPTURE_TIMEOUT', 30_000);
|
|
69
|
+
/**
|
|
70
|
+
* The transport-wide deadline, derived from the capture window rather than left
|
|
71
|
+
* at the library default.
|
|
72
|
+
*
|
|
73
|
+
* `captureRequestHeader`'s own `timeoutMs` is silently capped by
|
|
74
|
+
* `fetchTimeoutMs`: a window raised past the deadline fails early at the
|
|
75
|
+
* deadline's number, which reads as a timeout nobody configured. Deriving it
|
|
76
|
+
* means the two cannot disagree.
|
|
77
|
+
*/
|
|
78
|
+
const BRIDGE_DEADLINE_MS = Math.max(CAPTURE_TIMEOUT_MS + 15_000, 30_000);
|
|
79
|
+
/**
|
|
80
|
+
* Shortest plausible token. Real ones are ~5 KB of JWT; this only guards
|
|
81
|
+
* against an empty or echoed header, not against a wrong-audience token.
|
|
82
|
+
*/
|
|
83
|
+
const MIN_TOKEN_LENGTH = 100;
|
|
84
|
+
/**
|
|
85
|
+
* Add `fetch` to the derived capability set.
|
|
86
|
+
*
|
|
87
|
+
* `createBootstrapOpts` infers capabilities from the bootstrap DECLARATIONS and
|
|
88
|
+
* the field REPLACES the server's defaults rather than extending them, so a
|
|
89
|
+
* declaration-only set can silently lose `fetch`.
|
|
90
|
+
*/
|
|
91
|
+
function withFetch(opts) {
|
|
92
|
+
const derived = opts.capabilities ?? [];
|
|
93
|
+
return {
|
|
94
|
+
...opts,
|
|
95
|
+
capabilities: derived.includes('fetch')
|
|
96
|
+
? derived
|
|
97
|
+
: [...derived, 'fetch'],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Normalise a captured header to a bare token, dropping any `Bearer ` prefix. */
|
|
101
|
+
export function stripBearer(raw) {
|
|
102
|
+
const m = /^\s*Bearer\s+(.+)$/i.exec(raw);
|
|
103
|
+
return (m ? m[1] : raw).trim();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Capture an Outlook access token from the user's signed-in browser tab.
|
|
107
|
+
*
|
|
108
|
+
* Both hosts are raced: whichever the tab is actually on answers first, and the
|
|
109
|
+
* other simply never resolves. Returns the bare token (no `Bearer ` prefix).
|
|
110
|
+
*/
|
|
111
|
+
export async function captureTokenViaFetchproxy() {
|
|
112
|
+
const transport = createFetchproxyTransport({
|
|
113
|
+
...withFetch(createBootstrapOpts({
|
|
114
|
+
domains: ['cloud.microsoft', 'office.com'],
|
|
115
|
+
bootstrap: {
|
|
116
|
+
captureHeaders: [{ ...CAPTURE_DECL_CLOUD }, { ...CAPTURE_DECL_OFFICE }],
|
|
117
|
+
},
|
|
118
|
+
})),
|
|
119
|
+
port: getWsPort(),
|
|
120
|
+
fetchTimeoutMs: BRIDGE_DEADLINE_MS,
|
|
121
|
+
serverName: PACKAGE_NAME,
|
|
122
|
+
version: VERSION,
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
// `start()` loads the identity keypair. It does not bind the port or dial —
|
|
126
|
+
// that stays lazy until the first verb — but skipping it makes the first
|
|
127
|
+
// verb throw "ensureConnected called before listen()", which reads exactly
|
|
128
|
+
// like a pairing problem and is not one.
|
|
129
|
+
await transport.start();
|
|
130
|
+
const attempts = [CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE].map((decl) => transport.server
|
|
131
|
+
.captureRequestHeader({ ...decl, timeoutMs: CAPTURE_TIMEOUT_MS })
|
|
132
|
+
.then((raw) => {
|
|
133
|
+
if (typeof raw !== 'string') {
|
|
134
|
+
throw new Error(`${decl.host}: capture returned no header`);
|
|
135
|
+
}
|
|
136
|
+
const token = stripBearer(raw);
|
|
137
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
138
|
+
throw new Error(`${decl.host}: authorization header was present but too short to be a ` +
|
|
139
|
+
`token (${token.length} chars)`);
|
|
140
|
+
}
|
|
141
|
+
return token;
|
|
142
|
+
}));
|
|
143
|
+
// `any` resolves on the first SUCCESS rather than the first settle, so the
|
|
144
|
+
// host the user is not on failing does not sink the one they are on.
|
|
145
|
+
try {
|
|
146
|
+
return await Promise.any(attempts);
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
const detail = e instanceof AggregateError
|
|
150
|
+
? e.errors.map((x) => x.message).join('; ')
|
|
151
|
+
: e.message;
|
|
152
|
+
throw new Error(`could not capture an Outlook token from the browser (${detail}). ` +
|
|
153
|
+
'Open a signed-in Outlook tab (outlook.cloud.microsoft or ' +
|
|
154
|
+
'outlook.office.com), leave it in the foreground for a moment so the ' +
|
|
155
|
+
'page makes a request, and retry.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
try {
|
|
160
|
+
await transport.close();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* the token, or the original failure, is what matters */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|