@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
package/dist/client.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Outlook REST v2.0 client.
|
|
3
|
+
*
|
|
4
|
+
* Auth is a bearer token whose only source is the user's browser (see
|
|
5
|
+
* `auth-fetchproxy.ts`), or an `OUTLOOK_ACCESS_TOKEN` supplied directly. Once
|
|
6
|
+
* held, every request is a plain Node `fetch` — the bridge is never on the hot
|
|
7
|
+
* path.
|
|
8
|
+
*/
|
|
9
|
+
import { createApiClient, decodeJwtExp, McpToolError, parseBoolEnv, readEnvVar, } from '@chrischall/mcp-utils';
|
|
10
|
+
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
11
|
+
import { createTokenCache, reportCacheWriteFailure } from './token-cache.js';
|
|
12
|
+
/**
|
|
13
|
+
* Load the bridge bootstrap lazily.
|
|
14
|
+
*
|
|
15
|
+
* `auth-fetchproxy.ts` pulls in `@chrischall/mcp-utils/fetchproxy`, which
|
|
16
|
+
* imports the OPTIONAL `@fetchproxy/server` peer. That package is
|
|
17
|
+
* esbuild-`--external`, and the `.mcpb` bundle ships no `node_modules` — so a
|
|
18
|
+
* top-level import here would throw `ERR_MODULE_NOT_FOUND` the moment a host
|
|
19
|
+
* spawns the bundled server, before it can answer `initialize`. The host then
|
|
20
|
+
* reports only "Server transport closed unexpectedly".
|
|
21
|
+
*
|
|
22
|
+
* Deferring it means the default path never touches the package, and a user who
|
|
23
|
+
* supplies `OUTLOOK_ACCESS_TOKEN` never needs it installed at all.
|
|
24
|
+
*/
|
|
25
|
+
async function captureTokenLazily() {
|
|
26
|
+
const mod = await import('./auth-fetchproxy.js');
|
|
27
|
+
return mod.captureTokenViaFetchproxy();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The API the captured token is minted for.
|
|
31
|
+
*
|
|
32
|
+
* Note this is `outlook.office.com` even when the signed-in tab is on
|
|
33
|
+
* `outlook.cloud.microsoft` — the token's `aud` claim names this host, and it
|
|
34
|
+
* is the host that answers. Verified live 2026-09-20.
|
|
35
|
+
*/
|
|
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 };
|
|
66
|
+
}
|
|
67
|
+
export class OutlookClient {
|
|
68
|
+
#baseUrl;
|
|
69
|
+
/**
|
|
70
|
+
* Deferred configuration error.
|
|
71
|
+
*
|
|
72
|
+
* The constructor never throws, so the server still boots (and answers a
|
|
73
|
+
* host's install-time `tools/list` probe) with no credential present; the
|
|
74
|
+
* error surfaces on the first tool call instead.
|
|
75
|
+
*/
|
|
76
|
+
#configError = null;
|
|
77
|
+
#api = null;
|
|
78
|
+
#env;
|
|
79
|
+
#capture;
|
|
80
|
+
#fetchImpl;
|
|
81
|
+
#tokens = null;
|
|
82
|
+
#tokenSource = 'unconfigured';
|
|
83
|
+
constructor(opts = {}) {
|
|
84
|
+
this.#env = opts.env ?? process.env;
|
|
85
|
+
this.#capture = opts.captureToken ?? captureTokenLazily;
|
|
86
|
+
// A receiver-safe wrapper, never the bare global. Storing `fetch` itself
|
|
87
|
+
// and calling it as `this.#fetchImpl(...)` binds `this` to the client, and
|
|
88
|
+
// older undici (Node 18-20, which several MCP hosts still bundle) throws
|
|
89
|
+
// `Illegal invocation` for any receiver that is not `globalThis`.
|
|
90
|
+
this.#fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
91
|
+
this.#baseUrl = readEnvVar('OUTLOOK_API_BASE', { env: this.#env }) ?? DEFAULT_API_BASE;
|
|
92
|
+
const direct = readEnvVar('OUTLOOK_ACCESS_TOKEN', { env: this.#env });
|
|
93
|
+
const bridgeOff = parseBoolEnv('OUTLOOK_DISABLE_FETCHPROXY', { env: this.#env });
|
|
94
|
+
if (direct === undefined && bridgeOff) {
|
|
95
|
+
this.#configError = new McpToolError('No Outlook credential is configured.', {
|
|
96
|
+
hint: 'Set OUTLOOK_ACCESS_TOKEN, or unset OUTLOOK_DISABLE_FETCHPROXY so the ' +
|
|
97
|
+
'token can be captured from a signed-in Outlook browser tab.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
this.#tokenSource = direct !== undefined ? 'OUTLOOK_ACCESS_TOKEN' : 'browser capture';
|
|
101
|
+
}
|
|
102
|
+
/** Non-secret description of where the credential comes from. */
|
|
103
|
+
get tokenSource() {
|
|
104
|
+
return this.#tokenSource;
|
|
105
|
+
}
|
|
106
|
+
#requireConfigured() {
|
|
107
|
+
if (this.#configError)
|
|
108
|
+
throw this.#configError;
|
|
109
|
+
}
|
|
110
|
+
#tokenManager() {
|
|
111
|
+
if (this.#tokens)
|
|
112
|
+
return this.#tokens;
|
|
113
|
+
const direct = readEnvVar('OUTLOOK_ACCESS_TOKEN', { env: this.#env });
|
|
114
|
+
const persistence = direct === undefined ? createTokenCache(this.#env) : null;
|
|
115
|
+
const mint = async () => toBearerTokens(direct ?? (await this.#capture()));
|
|
116
|
+
this.#tokens = new TokenManager({
|
|
117
|
+
initial: mint,
|
|
118
|
+
// There is no refresh grant — re-capture instead. The sentinel argument
|
|
119
|
+
// is ignored by design.
|
|
120
|
+
//
|
|
121
|
+
// But a token supplied as OUTLOOK_ACCESS_TOKEN must NOT be "refreshed" by
|
|
122
|
+
// reaching for the browser: that silently swaps the credential the
|
|
123
|
+
// operator chose for whichever account a tab happens to be signed into,
|
|
124
|
+
// and on a machine with no bridge it just hangs for the capture window on
|
|
125
|
+
// every 401. Say what is wrong instead.
|
|
126
|
+
refresh: async () => {
|
|
127
|
+
if (direct !== undefined) {
|
|
128
|
+
throw new McpToolError('OUTLOOK_ACCESS_TOKEN was rejected by Outlook.', {
|
|
129
|
+
hint: 'A directly-supplied token cannot be refreshed automatically. Replace ' +
|
|
130
|
+
'OUTLOOK_ACCESS_TOKEN with a current one, or unset it to capture from a ' +
|
|
131
|
+
'signed-in Outlook browser tab instead.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return toBearerTokens(await this.#capture());
|
|
135
|
+
},
|
|
136
|
+
// A failed cache write must not fail the call that triggered it: the
|
|
137
|
+
// token is in hand and re-capturable, so a read-only data dir should cost
|
|
138
|
+
// the next cold start a capture, not this request.
|
|
139
|
+
...(persistence
|
|
140
|
+
? {
|
|
141
|
+
persistence: {
|
|
142
|
+
load: () => persistence.load(),
|
|
143
|
+
save: (t) => {
|
|
144
|
+
try {
|
|
145
|
+
persistence.save(t);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
reportCacheWriteFailure(e);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
clear: () => {
|
|
152
|
+
try {
|
|
153
|
+
persistence.clear();
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* nothing useful to do; the next capture overwrites it */
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
162
|
+
});
|
|
163
|
+
return this.#tokens;
|
|
164
|
+
}
|
|
165
|
+
#client() {
|
|
166
|
+
if (this.#api)
|
|
167
|
+
return this.#api;
|
|
168
|
+
this.#api = createApiClient({
|
|
169
|
+
baseUrl: this.#baseUrl,
|
|
170
|
+
tokenManager: this.#tokenManager(),
|
|
171
|
+
serviceName: 'Outlook',
|
|
172
|
+
fetchImpl: this.#fetchImpl,
|
|
173
|
+
timeout: 60_000,
|
|
174
|
+
baseHeaders: { Accept: 'application/json' },
|
|
175
|
+
onUnauthorized: () => new McpToolError('Outlook rejected the access token (401).', {
|
|
176
|
+
hint: 'The token has expired or been revoked. Open a signed-in Outlook tab ' +
|
|
177
|
+
'and retry — the next call re-captures automatically.',
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
return this.#api;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* GET a path relative to the API base.
|
|
184
|
+
*
|
|
185
|
+
* `text` asks Outlook to render message bodies as plain text instead of HTML
|
|
186
|
+
* — measured 9.4x smaller on a real message, and it also normalises a mixed
|
|
187
|
+
* HTML/Text collection to one type. `prefer` sets the `Prefer` header
|
|
188
|
+
* directly, for anything else the API takes there (notably
|
|
189
|
+
* `outlook.timezone="…"`).
|
|
190
|
+
*/
|
|
191
|
+
async get(path, opts = {}) {
|
|
192
|
+
this.#requireConfigured();
|
|
193
|
+
const prefer = [
|
|
194
|
+
opts.text ? 'outlook.body-content-type="text"' : undefined,
|
|
195
|
+
opts.prefer,
|
|
196
|
+
].filter((p) => p !== undefined);
|
|
197
|
+
return this.#client().fetchJson('GET', path, {
|
|
198
|
+
headers: prefer.length ? { Prefer: prefer.join(', ') } : undefined,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** GET an absolute URL — used only to follow `@odata.nextLink`. */
|
|
202
|
+
async getAbsolute(url) {
|
|
203
|
+
this.#requireConfigured();
|
|
204
|
+
const base = new URL(this.#baseUrl);
|
|
205
|
+
const target = new URL(url);
|
|
206
|
+
if (target.origin !== base.origin) {
|
|
207
|
+
throw new McpToolError(`Refusing to follow a link to ${target.origin}.`, {
|
|
208
|
+
hint: 'Pagination links must stay on the Outlook API host.',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return this.#client().fetchJson('GET', target.pathname + target.search);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The single write path. Everything mutating goes through here so auth and
|
|
215
|
+
* error shaping stay in one place.
|
|
216
|
+
*/
|
|
217
|
+
async write(method, path, body) {
|
|
218
|
+
this.#requireConfigured();
|
|
219
|
+
return this.#client().fetchJson(method, path, {
|
|
220
|
+
body: body === undefined ? undefined : body,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** The API base this client talks to. Non-secret; safe for healthcheck output. */
|
|
224
|
+
get apiBase() {
|
|
225
|
+
return this.#baseUrl;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Ensure a usable token is held, capturing or refreshing only if needed.
|
|
229
|
+
*
|
|
230
|
+
* `getAccessToken` is the same call every request makes, so this exercises
|
|
231
|
+
* the real resolution path rather than a re-derivation of it — the thing that
|
|
232
|
+
* makes a passing healthcheck mean real tools work.
|
|
233
|
+
*/
|
|
234
|
+
async refreshTokenIfNeeded() {
|
|
235
|
+
this.#requireConfigured();
|
|
236
|
+
await this.#tokenManager().getAccessToken();
|
|
237
|
+
}
|
|
238
|
+
/** Force a fresh capture, ignoring the cached token. */
|
|
239
|
+
async refreshToken() {
|
|
240
|
+
this.#requireConfigured();
|
|
241
|
+
await this.#tokenManager().refreshNow();
|
|
242
|
+
}
|
|
243
|
+
/** Epoch ms at which the current token expires, or `null` if none is held. */
|
|
244
|
+
tokenExpiresAt() {
|
|
245
|
+
return this.#tokens ? this.#tokens.getExpiresAt() : null;
|
|
246
|
+
}
|
|
247
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMcp, loadDotenvSafely } from '@chrischall/mcp-utils';
|
|
3
|
+
import { VERSION } from './version.js';
|
|
4
|
+
import { OutlookClient } from './client.js';
|
|
5
|
+
import { registerMailTools } from './tools/mail.js';
|
|
6
|
+
import { registerCalendarTools } from './tools/calendar.js';
|
|
7
|
+
import { registerDirectoryTools } from './tools/directory.js';
|
|
8
|
+
import { registerWriteTools } from './tools/writes.js';
|
|
9
|
+
import { registerHealthcheckTool } from './tools/healthcheck.js';
|
|
10
|
+
loadDotenvSafely();
|
|
11
|
+
/**
|
|
12
|
+
* Built HERE, in the caller, not inside a registrar.
|
|
13
|
+
*
|
|
14
|
+
* `runMcp` constructs a server instance per served connection — and one more
|
|
15
|
+
* for a `server/discover` probe that the client then discards — so the
|
|
16
|
+
* registrars run more than once. Anything built inside one would be rebuilt,
|
|
17
|
+
* which for this client would mean a second TokenManager and a second browser
|
|
18
|
+
* capture. Registrars register; state lives in `deps`.
|
|
19
|
+
*
|
|
20
|
+
* The constructor also never throws on missing config, so the server boots and
|
|
21
|
+
* answers an install-time `tools/list` with no credential present.
|
|
22
|
+
*/
|
|
23
|
+
const client = new OutlookClient();
|
|
24
|
+
await runMcp({
|
|
25
|
+
name: 'office-outlook-mcp',
|
|
26
|
+
version: VERSION,
|
|
27
|
+
banner: '[office-outlook-mcp] This project was developed and is maintained by AI. Use at your own discretion.',
|
|
28
|
+
deps: client,
|
|
29
|
+
tools: [
|
|
30
|
+
registerMailTools,
|
|
31
|
+
registerCalendarTools,
|
|
32
|
+
registerDirectoryTools,
|
|
33
|
+
registerWriteTools,
|
|
34
|
+
registerHealthcheckTool,
|
|
35
|
+
],
|
|
36
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
|
|
2
|
+
import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
|
|
3
|
+
/**
|
|
4
|
+
* Where the captured Outlook access token is cached between runs.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately its OWN directory. The `outlook-fpx` access skill keeps a token
|
|
7
|
+
* in `~/.outlook-fpx/curlrc` in a different format; writing there would corrupt
|
|
8
|
+
* whichever of the two ran second.
|
|
9
|
+
*/
|
|
10
|
+
export function tokenCachePath(env = process.env) {
|
|
11
|
+
return resolveStateFile({
|
|
12
|
+
env,
|
|
13
|
+
envVar: 'OUTLOOK_TOKEN_FILE',
|
|
14
|
+
subdir: '.office-outlook-mcp',
|
|
15
|
+
fileName: 'token.json',
|
|
16
|
+
});
|
|
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
|
+
/**
|
|
28
|
+
* What a cached token is bound to, or `null` when there is nothing worth
|
|
29
|
+
* caching.
|
|
30
|
+
*
|
|
31
|
+
* An `OUTLOOK_ACCESS_TOKEN` IS the environment variable — there is no capture
|
|
32
|
+
* to skip, so caching it would only copy a credential onto disk. A captured
|
|
33
|
+
* token is worth caching most of all: it lets a cold start proceed with no
|
|
34
|
+
* browser present, which on a host that has none is the difference between
|
|
35
|
+
* working and not.
|
|
36
|
+
*/
|
|
37
|
+
function bindingFor(env) {
|
|
38
|
+
if (readEnvVar('OUTLOOK_ACCESS_TOKEN', { env }) !== undefined)
|
|
39
|
+
return null;
|
|
40
|
+
if (parseBoolEnv('OUTLOOK_DISABLE_FETCHPROXY', { env }))
|
|
41
|
+
return null;
|
|
42
|
+
return 'fetchproxy';
|
|
43
|
+
}
|
|
44
|
+
/** The token cache, or `null` when it is off or there is nothing to cache. */
|
|
45
|
+
export function createTokenCache(env = process.env) {
|
|
46
|
+
if (!parseBoolEnv('OUTLOOK_TOKEN_CACHE', { env, default: true }))
|
|
47
|
+
return null;
|
|
48
|
+
const boundTo = bindingFor(env);
|
|
49
|
+
if (boundTo === null)
|
|
50
|
+
return null;
|
|
51
|
+
return createFileStatePersistence({
|
|
52
|
+
filePath: tokenCachePath(env),
|
|
53
|
+
boundTo,
|
|
54
|
+
validate: (raw) => (isToken(raw) ? raw : null),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Report a cache write that failed. Not fatal — the token is re-capturable —
|
|
59
|
+
* but worth saying, because a read-only data dir otherwise looks exactly like a
|
|
60
|
+
* server that simply never caches.
|
|
61
|
+
*
|
|
62
|
+
* stderr only; stdout is the JSON-RPC channel.
|
|
63
|
+
*/
|
|
64
|
+
export function reportCacheWriteFailure(err) {
|
|
65
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
66
|
+
console.error(`[office-outlook-mcp] could not cache the access token (${detail}); continuing ` +
|
|
67
|
+
'without the cache — every restart will re-capture from the browser.');
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { schemaConfirm, minifiedResult } from '@chrischall/mcp-utils';
|
|
2
|
+
export { schemaConfirm };
|
|
3
|
+
/**
|
|
4
|
+
* Confirm-gate for a mutating tool (the fleet convention). Without
|
|
5
|
+
* `confirm: true` the tool makes NO network call and returns a dry-run preview
|
|
6
|
+
* of exactly what would be sent.
|
|
7
|
+
*
|
|
8
|
+
* This matters more here than in most repos: these writes send mail from the
|
|
9
|
+
* user's real work address and move items in a live mailbox. A single
|
|
10
|
+
* hallucinated call must not fire silently.
|
|
11
|
+
*
|
|
12
|
+
* Note this is a parameter, not an MCP elicitation. claude.ai declares no
|
|
13
|
+
* elicitation capability and answers `-32021`, so a write that tried to ask
|
|
14
|
+
* would simply fail there.
|
|
15
|
+
*/
|
|
16
|
+
export function previewUnlessConfirmed(confirm, action, method, path, body) {
|
|
17
|
+
if (confirm === true)
|
|
18
|
+
return null;
|
|
19
|
+
return minifiedResult({
|
|
20
|
+
dryRun: true,
|
|
21
|
+
action,
|
|
22
|
+
method,
|
|
23
|
+
path,
|
|
24
|
+
...(body !== undefined ? { willSend: body } : {}),
|
|
25
|
+
note: 'Re-run with confirm: true to execute.',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
|
+
import { compactEvent, fullEvent, projectCollection } from '../view.js';
|
|
4
|
+
import { VIEWS } from './mail.js';
|
|
5
|
+
const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview';
|
|
6
|
+
function qs(params) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
for (const [k, v] of Object.entries(params)) {
|
|
9
|
+
if (v === undefined || v === '')
|
|
10
|
+
continue;
|
|
11
|
+
parts.push(`${k}=${encodeURIComponent(String(v))}`);
|
|
12
|
+
}
|
|
13
|
+
return parts.length ? `?${parts.join('&')}` : '';
|
|
14
|
+
}
|
|
15
|
+
export function registerCalendarTools(server, client) {
|
|
16
|
+
server.registerTool('outlook_list_events', {
|
|
17
|
+
description: 'List calendar events in a date window. This uses the calendar VIEW, which expands recurring series into their individual occurrences — the right tool for "what is on my schedule". Times are returned in `timeZone` when given.',
|
|
18
|
+
annotations: { readOnlyHint: true },
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
view: viewParam(VIEWS),
|
|
21
|
+
start: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.describe('Window start, ISO 8601 (e.g. 2026-09-20T00:00:00Z)'),
|
|
25
|
+
end: z.string().min(1).describe('Window end, ISO 8601'),
|
|
26
|
+
timeZone: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Windows time-zone name for the returned times, e.g. "Eastern Standard Time". NOT an IANA name like America/New_York.'),
|
|
30
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max events (default 50)'),
|
|
31
|
+
}),
|
|
32
|
+
}, async ({ view, start, end, timeZone, limit }) => {
|
|
33
|
+
const data = await client.get(`/me/calendarview${qs({
|
|
34
|
+
startDateTime: start,
|
|
35
|
+
endDateTime: end,
|
|
36
|
+
$select: EVENT_SELECT,
|
|
37
|
+
$orderby: 'Start/DateTime',
|
|
38
|
+
$top: limit ?? 50,
|
|
39
|
+
})}`, timeZone ? { prefer: `outlook.timezone="${timeZone}"` } : {});
|
|
40
|
+
const v = resolveView(view, VIEWS);
|
|
41
|
+
if (v === 'raw')
|
|
42
|
+
return minifiedResult(data);
|
|
43
|
+
return minifiedResult(projectCollection(data, v === 'full' ? fullEvent : compactEvent, 'event'));
|
|
44
|
+
});
|
|
45
|
+
server.registerTool('outlook_get_event', {
|
|
46
|
+
description: 'Get one calendar event in full, including attendees and their responses.',
|
|
47
|
+
annotations: { readOnlyHint: true },
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
view: viewParam(VIEWS),
|
|
50
|
+
id: z.string().min(1).describe('Event Id from outlook_list_events'),
|
|
51
|
+
timeZone: z.string().optional().describe('Windows time-zone name for returned times'),
|
|
52
|
+
}),
|
|
53
|
+
}, async ({ view, id, timeZone }) => {
|
|
54
|
+
const data = await client.get(`/me/events/${encodeURIComponent(id)}`, timeZone ? { prefer: `outlook.timezone="${timeZone}"` } : {});
|
|
55
|
+
const v = resolveView(view, VIEWS);
|
|
56
|
+
if (v === 'raw')
|
|
57
|
+
return minifiedResult(data);
|
|
58
|
+
return minifiedResult(v === 'compact' ? compactEvent(data) : fullEvent(data));
|
|
59
|
+
});
|
|
60
|
+
server.registerTool('outlook_list_calendars', {
|
|
61
|
+
description: 'List the calendars in the mailbox, for picking one to read or write against.',
|
|
62
|
+
annotations: { readOnlyHint: true },
|
|
63
|
+
inputSchema: z.object({
|
|
64
|
+
view: viewParam(VIEWS),
|
|
65
|
+
}),
|
|
66
|
+
}, async ({ view }) => {
|
|
67
|
+
const data = await client.get(`/me/calendars${qs({ $select: 'Id,Name,Color,CanEdit,Owner' })}`);
|
|
68
|
+
if (resolveView(view, VIEWS) === 'raw')
|
|
69
|
+
return minifiedResult(data);
|
|
70
|
+
return minifiedResult({ count: data.value?.length ?? 0, items: data.value ?? [] });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
|
+
import { VIEWS } from './mail.js';
|
|
4
|
+
function qs(params) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
for (const [k, v] of Object.entries(params)) {
|
|
7
|
+
if (v === undefined || v === '')
|
|
8
|
+
continue;
|
|
9
|
+
parts.push(`${k}=${encodeURIComponent(String(v))}`);
|
|
10
|
+
}
|
|
11
|
+
return parts.length ? `?${parts.join('&')}` : '';
|
|
12
|
+
}
|
|
13
|
+
function collection(data, raw) {
|
|
14
|
+
if (raw)
|
|
15
|
+
return data;
|
|
16
|
+
return { count: data.value?.length ?? 0, items: data.value ?? [] };
|
|
17
|
+
}
|
|
18
|
+
export function registerDirectoryTools(server, client) {
|
|
19
|
+
server.registerTool('outlook_get_profile', {
|
|
20
|
+
description: "Get the signed-in mailbox's identity (email address, display name, alias). The cheapest call that proves the credential works.",
|
|
21
|
+
annotations: { readOnlyHint: true },
|
|
22
|
+
inputSchema: z.object({}),
|
|
23
|
+
}, async () => minifiedResult(await client.get('/me')));
|
|
24
|
+
server.registerTool('outlook_get_mailbox_settings', {
|
|
25
|
+
description: 'Get mailbox settings: time zone, working hours, language, and automatic-replies (out-of-office) configuration.',
|
|
26
|
+
annotations: { readOnlyHint: true },
|
|
27
|
+
inputSchema: z.object({}),
|
|
28
|
+
}, async () => minifiedResult(await client.get('/me/MailboxSettings')));
|
|
29
|
+
server.registerTool('outlook_list_contacts', {
|
|
30
|
+
description: 'List saved contacts from the mailbox address book.',
|
|
31
|
+
annotations: { readOnlyHint: true },
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
view: viewParam(VIEWS),
|
|
34
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max contacts (default 50)'),
|
|
35
|
+
skip: z.number().int().min(0).optional().describe('Offset for paging'),
|
|
36
|
+
}),
|
|
37
|
+
}, async ({ view, limit, skip }) => {
|
|
38
|
+
const data = await client.get(`/me/contacts${qs({
|
|
39
|
+
$top: limit ?? 50,
|
|
40
|
+
$skip: skip,
|
|
41
|
+
$select: 'Id,DisplayName,EmailAddresses,CompanyName,JobTitle,MobilePhone',
|
|
42
|
+
})}`);
|
|
43
|
+
return minifiedResult(collection(data, resolveView(view, VIEWS) === 'raw'));
|
|
44
|
+
});
|
|
45
|
+
server.registerTool('outlook_list_people', {
|
|
46
|
+
description: 'List people ranked by relevance to the user — colleagues and frequent correspondents, drawn from mail traffic rather than the saved address book. Better than outlook_list_contacts for resolving "who is X" in a work mailbox.',
|
|
47
|
+
annotations: { readOnlyHint: true },
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
view: viewParam(VIEWS),
|
|
50
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max people (default 25)'),
|
|
51
|
+
}),
|
|
52
|
+
}, async ({ view, limit }) => {
|
|
53
|
+
const data = await client.get(`/me/people${qs({
|
|
54
|
+
$top: limit ?? 25,
|
|
55
|
+
$select: 'Id,DisplayName,ScoredEmailAddresses,JobTitle,CompanyName',
|
|
56
|
+
})}`);
|
|
57
|
+
return minifiedResult(collection(data, resolveView(view, VIEWS) === 'raw'));
|
|
58
|
+
});
|
|
59
|
+
server.registerTool('outlook_list_tasks', {
|
|
60
|
+
description: 'List Outlook tasks with status and due dates.',
|
|
61
|
+
annotations: { readOnlyHint: true },
|
|
62
|
+
inputSchema: z.object({
|
|
63
|
+
view: viewParam(VIEWS),
|
|
64
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max tasks (default 50)'),
|
|
65
|
+
}),
|
|
66
|
+
}, async ({ view, limit }) => {
|
|
67
|
+
const data = await client.get(`/me/tasks${qs({
|
|
68
|
+
$top: limit ?? 50,
|
|
69
|
+
$select: 'Id,Subject,Status,Importance,DueDateTime,CompletedDateTime',
|
|
70
|
+
})}`);
|
|
71
|
+
return minifiedResult(collection(data, resolveView(view, VIEWS) === 'raw'));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
|
|
2
|
+
/**
|
|
3
|
+
* The CREDENTIAL healthcheck, not the bridge one.
|
|
4
|
+
*
|
|
5
|
+
* Classify by where the transport is HELD, not by what is imported: this repo
|
|
6
|
+
* constructs a fetchproxy transport ephemerally inside the token capture and
|
|
7
|
+
* closes it immediately, then talks to `outlook.office.com` over plain fetch.
|
|
8
|
+
* The bridge is not on the request path, so bridge role/port would be the wrong
|
|
9
|
+
* thing to report — what a broken install needs to know is whether a token
|
|
10
|
+
* resolved and whether Outlook accepted it.
|
|
11
|
+
*
|
|
12
|
+
* Imported from `/healthcheck`, deliberately NOT from `/fetchproxy`: that
|
|
13
|
+
* module pulls in the optional `@fetchproxy/server` peer, which is absent in
|
|
14
|
+
* the `.mcpb` bundle.
|
|
15
|
+
*/
|
|
16
|
+
export function registerHealthcheckTool(server, client) {
|
|
17
|
+
registerCredentialHealthcheckTool({
|
|
18
|
+
server,
|
|
19
|
+
prefix: 'outlook',
|
|
20
|
+
hostLabel: 'outlook.office.com',
|
|
21
|
+
probePath: '/api/v2.0/me',
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the credential the way the real tools do — same TokenManager,
|
|
24
|
+
* same cache, same capture — so a passing healthcheck means real tools
|
|
25
|
+
* work. Returns a SOURCE LABEL and non-secret detail, never the token:
|
|
26
|
+
* this result is what people paste into a chat when something is broken.
|
|
27
|
+
*/
|
|
28
|
+
resolveCredential: async () => {
|
|
29
|
+
await client.refreshTokenIfNeeded();
|
|
30
|
+
const expiresAt = client.tokenExpiresAt();
|
|
31
|
+
return {
|
|
32
|
+
source: client.tokenSource,
|
|
33
|
+
detail: {
|
|
34
|
+
expiresAt: expiresAt === null ? null : new Date(expiresAt).toISOString(),
|
|
35
|
+
minutesRemaining: expiresAt === null ? null : Math.round((expiresAt - Date.now()) / 60_000),
|
|
36
|
+
apiBase: client.apiBase,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
probeFn: async () => {
|
|
41
|
+
const me = await client.get('/me');
|
|
42
|
+
return `authenticated as ${me?.EmailAddress ?? 'unknown'}`;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|