@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/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
+ }