@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.
@@ -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
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "outlook": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/bundle.js"],
6
+ "env": {
7
+ "OUTLOOK_ACCESS_TOKEN": "${OUTLOOK_ACCESS_TOKEN}"
8
+ }
9
+ }
10
+ }
11
+ }
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
+ }