@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,130 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { minifiedResult, resolveView, viewParam, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { compactFolder, compactMessage, fullMessage, projectCollection, } from '../view.js';
|
|
4
|
+
export const VIEWS = ['compact', 'full', 'raw'];
|
|
5
|
+
/**
|
|
6
|
+
* Well-known folder names the API accepts wherever a folder id is taken.
|
|
7
|
+
* All eight verified present on a live mailbox 2026-09-20.
|
|
8
|
+
*/
|
|
9
|
+
const WELL_KNOWN = [
|
|
10
|
+
'inbox',
|
|
11
|
+
'drafts',
|
|
12
|
+
'sentitems',
|
|
13
|
+
'deleteditems',
|
|
14
|
+
'archive',
|
|
15
|
+
'junkemail',
|
|
16
|
+
'outbox',
|
|
17
|
+
'clutter',
|
|
18
|
+
];
|
|
19
|
+
/** Fields worth listing. Anything not here costs bytes on every row. */
|
|
20
|
+
const LIST_SELECT = 'Id,Subject,From,ToRecipients,ReceivedDateTime,IsRead,HasAttachments,BodyPreview';
|
|
21
|
+
function qs(params) {
|
|
22
|
+
const parts = [];
|
|
23
|
+
for (const [k, v] of Object.entries(params)) {
|
|
24
|
+
if (v === undefined || v === '')
|
|
25
|
+
continue;
|
|
26
|
+
parts.push(`${k}=${encodeURIComponent(String(v))}`);
|
|
27
|
+
}
|
|
28
|
+
return parts.length ? `?${parts.join('&')}` : '';
|
|
29
|
+
}
|
|
30
|
+
/** `inbox` etc. pass through; anything else is treated as an opaque folder id. */
|
|
31
|
+
function folderSegment(folder) {
|
|
32
|
+
return encodeURIComponent(folder);
|
|
33
|
+
}
|
|
34
|
+
export function registerMailTools(server, client) {
|
|
35
|
+
server.registerTool('outlook_list_folders', {
|
|
36
|
+
description: 'List mail folders with unread and total counts. Use this to discover folder ids before listing messages; the well-known names (' +
|
|
37
|
+
WELL_KNOWN.join(', ') +
|
|
38
|
+
') can be used directly without a lookup.',
|
|
39
|
+
annotations: { readOnlyHint: true },
|
|
40
|
+
inputSchema: z.object({
|
|
41
|
+
view: viewParam(VIEWS),
|
|
42
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max folders (default 50)'),
|
|
43
|
+
}),
|
|
44
|
+
}, async ({ view, limit }) => {
|
|
45
|
+
const data = await client.get(`/me/mailfolders${qs({ $top: limit ?? 50 })}`);
|
|
46
|
+
if (resolveView(view, VIEWS) === 'raw')
|
|
47
|
+
return minifiedResult(data);
|
|
48
|
+
return minifiedResult(projectCollection(data, compactFolder, 'mail folder'));
|
|
49
|
+
});
|
|
50
|
+
server.registerTool('outlook_list_messages', {
|
|
51
|
+
description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included — call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined (the API rejects $search with $filter); pass `search` alone to search the whole mailbox.",
|
|
52
|
+
annotations: { readOnlyHint: true },
|
|
53
|
+
inputSchema: z.object({
|
|
54
|
+
view: viewParam(VIEWS),
|
|
55
|
+
folder: z
|
|
56
|
+
.string()
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('Folder id or well-known name (default "inbox")'),
|
|
59
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max messages (default 25)'),
|
|
60
|
+
skip: z.number().int().min(0).optional().describe('Offset for paging'),
|
|
61
|
+
unreadOnly: z.boolean().optional().describe('Only unread messages'),
|
|
62
|
+
search: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('Full-text search across the whole mailbox; cannot combine with unreadOnly'),
|
|
66
|
+
}),
|
|
67
|
+
}, async ({ view, folder, limit, skip, unreadOnly, search }) => {
|
|
68
|
+
if (search !== undefined && unreadOnly === true) {
|
|
69
|
+
throw new McpToolError('`search` and `unreadOnly` cannot be combined.', {
|
|
70
|
+
hint: 'Outlook rejects $search together with $filter. Search first, then filter the results.',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// $search always spans the mailbox; scoping it to a folder is not supported.
|
|
74
|
+
const base = search !== undefined ? '/me/messages' : `/me/mailfolders/${folderSegment(folder ?? 'inbox')}/messages`;
|
|
75
|
+
const params = {
|
|
76
|
+
$top: limit ?? 25,
|
|
77
|
+
$skip: skip,
|
|
78
|
+
$select: LIST_SELECT,
|
|
79
|
+
};
|
|
80
|
+
if (search !== undefined) {
|
|
81
|
+
params.$search = `"${search.replace(/"/g, '')}"`;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// $orderby is rejected alongside $search, so it is set only here.
|
|
85
|
+
params.$orderby = 'ReceivedDateTime desc';
|
|
86
|
+
if (unreadOnly === true)
|
|
87
|
+
params.$filter = 'IsRead eq false';
|
|
88
|
+
}
|
|
89
|
+
const data = await client.get(`${base}${qs(params)}`);
|
|
90
|
+
const v = resolveView(view, VIEWS);
|
|
91
|
+
if (v === 'raw')
|
|
92
|
+
return minifiedResult(data);
|
|
93
|
+
return minifiedResult(projectCollection(data, v === 'full' ? fullMessage : compactMessage, 'message'));
|
|
94
|
+
});
|
|
95
|
+
server.registerTool('outlook_get_message', {
|
|
96
|
+
description: 'Get one message including its body. The body is requested as plain text rather than HTML (measured ~9x smaller and far easier to read); pass view:"raw" to get Outlook\'s untouched record.',
|
|
97
|
+
annotations: { readOnlyHint: true },
|
|
98
|
+
inputSchema: z.object({
|
|
99
|
+
view: viewParam(VIEWS),
|
|
100
|
+
id: z.string().min(1).describe('Message Id from outlook_list_messages'),
|
|
101
|
+
html: z
|
|
102
|
+
.boolean()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe('Return the HTML body instead of plain text (default false)'),
|
|
105
|
+
}),
|
|
106
|
+
}, async ({ view, id, html }) => {
|
|
107
|
+
const data = await client.get(`/me/messages/${encodeURIComponent(id)}`, {
|
|
108
|
+
text: html !== true,
|
|
109
|
+
});
|
|
110
|
+
const v = resolveView(view, VIEWS);
|
|
111
|
+
if (v === 'raw')
|
|
112
|
+
return minifiedResult(data);
|
|
113
|
+
return minifiedResult(v === 'full' ? fullMessage(data) : compactMessage(data));
|
|
114
|
+
});
|
|
115
|
+
server.registerTool('outlook_list_attachments', {
|
|
116
|
+
description: "List a message's attachments (name, size, content type). Metadata only — this deliberately does not download bytes.",
|
|
117
|
+
annotations: { readOnlyHint: true },
|
|
118
|
+
inputSchema: z.object({
|
|
119
|
+
id: z.string().min(1).describe('Message Id'),
|
|
120
|
+
}),
|
|
121
|
+
}, async ({ id }) => {
|
|
122
|
+
const data = await client.get(`/me/messages/${encodeURIComponent(id)}/attachments${qs({
|
|
123
|
+
$select: 'Id,Name,Size,ContentType',
|
|
124
|
+
})}`);
|
|
125
|
+
return minifiedResult({
|
|
126
|
+
count: data.value?.length ?? 0,
|
|
127
|
+
items: data.value ?? [],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { minifiedResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
|
+
const recipientList = z
|
|
5
|
+
.array(z.string().min(3))
|
|
6
|
+
.optional()
|
|
7
|
+
.describe('Email addresses');
|
|
8
|
+
function toRecipients(list) {
|
|
9
|
+
return (list ?? []).map((Address) => ({ EmailAddress: { Address } }));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Body shape for a message. `ContentType` is `Text` by default — an agent
|
|
13
|
+
* composing prose should not have to emit HTML, and Outlook renders text fine.
|
|
14
|
+
*/
|
|
15
|
+
function messageBody(subject, body, html) {
|
|
16
|
+
return {
|
|
17
|
+
Subject: subject,
|
|
18
|
+
Body: { ContentType: html === true ? 'HTML' : 'Text', Content: body },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function registerWriteTools(server, client) {
|
|
22
|
+
server.registerTool('outlook_send_mail', {
|
|
23
|
+
description: 'Send an email from the signed-in mailbox. Requires confirm:true — without it this makes no network call and returns a preview of exactly what would be sent.',
|
|
24
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
25
|
+
inputSchema: z.object({
|
|
26
|
+
to: recipientList,
|
|
27
|
+
cc: recipientList,
|
|
28
|
+
bcc: recipientList,
|
|
29
|
+
subject: z.string().describe('Subject line'),
|
|
30
|
+
body: z.string().describe('Message body'),
|
|
31
|
+
html: z.boolean().optional().describe('Send the body as HTML (default: plain text)'),
|
|
32
|
+
saveToSentItems: z.boolean().optional().describe('Keep a copy in Sent Items (default true)'),
|
|
33
|
+
confirm: schemaConfirm,
|
|
34
|
+
}),
|
|
35
|
+
}, async ({ to, cc, bcc, subject, body, html, saveToSentItems, confirm }) => {
|
|
36
|
+
const payload = {
|
|
37
|
+
Message: {
|
|
38
|
+
...messageBody(subject, body, html),
|
|
39
|
+
ToRecipients: toRecipients(to),
|
|
40
|
+
...(cc?.length ? { CcRecipients: toRecipients(cc) } : {}),
|
|
41
|
+
...(bcc?.length ? { BccRecipients: toRecipients(bcc) } : {}),
|
|
42
|
+
},
|
|
43
|
+
SaveToSentItems: saveToSentItems !== false,
|
|
44
|
+
};
|
|
45
|
+
const recipients = [...(to ?? []), ...(cc ?? []), ...(bcc ?? [])].join(', ');
|
|
46
|
+
const gate = previewUnlessConfirmed(confirm, `Send mail "${subject}" to ${recipients || '(no recipients)'}`, 'POST', '/me/sendmail', payload);
|
|
47
|
+
if (gate)
|
|
48
|
+
return gate;
|
|
49
|
+
await client.write('POST', '/me/sendmail', payload);
|
|
50
|
+
// sendmail returns 202 with an empty body; there is no id to report and
|
|
51
|
+
// no resource to re-read, so say exactly what is known.
|
|
52
|
+
return minifiedResult({ sent: true, subject, recipients: recipients || null });
|
|
53
|
+
});
|
|
54
|
+
server.registerTool('outlook_create_draft', {
|
|
55
|
+
description: 'Create a draft message in the Drafts folder without sending it. Requires confirm:true. Returns the created draft, which can be reviewed and sent from Outlook.',
|
|
56
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
57
|
+
inputSchema: z.object({
|
|
58
|
+
to: recipientList,
|
|
59
|
+
cc: recipientList,
|
|
60
|
+
subject: z.string().describe('Subject line'),
|
|
61
|
+
body: z.string().describe('Message body'),
|
|
62
|
+
html: z.boolean().optional().describe('Compose the body as HTML (default: plain text)'),
|
|
63
|
+
confirm: schemaConfirm,
|
|
64
|
+
}),
|
|
65
|
+
}, async ({ to, cc, subject, body, html, confirm }) => {
|
|
66
|
+
const payload = {
|
|
67
|
+
...messageBody(subject, body, html),
|
|
68
|
+
ToRecipients: toRecipients(to),
|
|
69
|
+
...(cc?.length ? { CcRecipients: toRecipients(cc) } : {}),
|
|
70
|
+
};
|
|
71
|
+
const gate = previewUnlessConfirmed(confirm, `Create draft "${subject}"`, 'POST', '/me/messages', payload);
|
|
72
|
+
if (gate)
|
|
73
|
+
return gate;
|
|
74
|
+
const created = await client.write('POST', '/me/messages', payload);
|
|
75
|
+
return minifiedResult({ created: true, Id: created?.Id, WebLink: created?.WebLink });
|
|
76
|
+
});
|
|
77
|
+
server.registerTool('outlook_mark_read', {
|
|
78
|
+
description: 'Mark a message read or unread. Requires confirm:true. The result is verified by re-reading the message — a 2xx alone is not proof it persisted.',
|
|
79
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
id: z.string().min(1).describe('Message Id'),
|
|
82
|
+
isRead: z.boolean().describe('true to mark read, false to mark unread'),
|
|
83
|
+
confirm: schemaConfirm,
|
|
84
|
+
}),
|
|
85
|
+
}, async ({ id, isRead, confirm }) => {
|
|
86
|
+
const path = `/me/messages/${encodeURIComponent(id)}`;
|
|
87
|
+
const payload = { IsRead: isRead };
|
|
88
|
+
const gate = previewUnlessConfirmed(confirm, `Mark message ${id} as ${isRead ? 'read' : 'unread'}`, 'PATCH', path, payload);
|
|
89
|
+
if (gate)
|
|
90
|
+
return gate;
|
|
91
|
+
await client.write('PATCH', path, payload);
|
|
92
|
+
// Re-read rather than trust the status. IsRead is the field the write
|
|
93
|
+
// requested, so it is the field that proves it.
|
|
94
|
+
const after = await client.get(`${path}?$select=IsRead`);
|
|
95
|
+
return minifiedResult({
|
|
96
|
+
updated: after?.IsRead === isRead,
|
|
97
|
+
IsRead: after?.IsRead,
|
|
98
|
+
...(after?.IsRead === isRead ? {} : { warning: 'Outlook accepted the write but the value did not change.' }),
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
server.registerTool('outlook_move_message', {
|
|
102
|
+
description: 'Move a message to another folder (e.g. "archive", "deleteditems", or a folder id from outlook_list_folders). Requires confirm:true. Moving assigns a NEW message id, which is returned.',
|
|
103
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
104
|
+
inputSchema: z.object({
|
|
105
|
+
id: z.string().min(1).describe('Message Id'),
|
|
106
|
+
destination: z
|
|
107
|
+
.string()
|
|
108
|
+
.min(1)
|
|
109
|
+
.describe('Destination folder id or well-known name (e.g. "archive")'),
|
|
110
|
+
confirm: schemaConfirm,
|
|
111
|
+
}),
|
|
112
|
+
}, async ({ id, destination, confirm }) => {
|
|
113
|
+
const path = `/me/messages/${encodeURIComponent(id)}/move`;
|
|
114
|
+
const payload = { DestinationId: destination };
|
|
115
|
+
const gate = previewUnlessConfirmed(confirm, `Move message ${id} to ${destination}`, 'POST', path, payload);
|
|
116
|
+
if (gate)
|
|
117
|
+
return gate;
|
|
118
|
+
const moved = await client.write('POST', path, payload);
|
|
119
|
+
return minifiedResult({
|
|
120
|
+
moved: true,
|
|
121
|
+
newId: moved?.Id,
|
|
122
|
+
ParentFolderId: moved?.ParentFolderId,
|
|
123
|
+
note: 'The message has a new Id after a move; the old one no longer resolves.',
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
server.registerTool('outlook_create_event', {
|
|
127
|
+
description: 'Create a calendar event. Requires confirm:true. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name.',
|
|
128
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
129
|
+
inputSchema: z.object({
|
|
130
|
+
subject: z.string().describe('Event title'),
|
|
131
|
+
start: z.string().min(1).describe('Start, ISO 8601 local time e.g. 2026-09-22T15:00:00'),
|
|
132
|
+
end: z.string().min(1).describe('End, ISO 8601 local time'),
|
|
133
|
+
timeZone: z
|
|
134
|
+
.string()
|
|
135
|
+
.optional()
|
|
136
|
+
.describe('Windows time-zone name (default "UTC")'),
|
|
137
|
+
location: z.string().optional().describe('Location display name'),
|
|
138
|
+
body: z.string().optional().describe('Event description'),
|
|
139
|
+
attendees: recipientList,
|
|
140
|
+
confirm: schemaConfirm,
|
|
141
|
+
}),
|
|
142
|
+
}, async ({ subject, start, end, timeZone, location, body, attendees, confirm }) => {
|
|
143
|
+
const tz = timeZone ?? 'UTC';
|
|
144
|
+
const payload = {
|
|
145
|
+
Subject: subject,
|
|
146
|
+
Start: { DateTime: start, TimeZone: tz },
|
|
147
|
+
End: { DateTime: end, TimeZone: tz },
|
|
148
|
+
...(location ? { Location: { DisplayName: location } } : {}),
|
|
149
|
+
...(body ? { Body: { ContentType: 'Text', Content: body } } : {}),
|
|
150
|
+
...(attendees?.length
|
|
151
|
+
? {
|
|
152
|
+
Attendees: attendees.map((Address) => ({
|
|
153
|
+
EmailAddress: { Address },
|
|
154
|
+
Type: 'Required',
|
|
155
|
+
})),
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
};
|
|
159
|
+
const gate = previewUnlessConfirmed(confirm, `Create event "${subject}" ${start} to ${end} (${tz})`, 'POST', '/me/events', payload);
|
|
160
|
+
if (gate)
|
|
161
|
+
return gate;
|
|
162
|
+
const created = await client.write('POST', '/me/events', payload);
|
|
163
|
+
return minifiedResult({ created: true, Id: created?.Id, WebLink: created?.WebLink });
|
|
164
|
+
});
|
|
165
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of the package version.
|
|
3
|
+
*
|
|
4
|
+
* Everything that needs a version imports from here, so release-please has one
|
|
5
|
+
* file to rewrite and there is no second marker to forget. Registered in
|
|
6
|
+
* `release-please-config.json` under `extra-files`.
|
|
7
|
+
*
|
|
8
|
+
* Note for anyone editing the comments in this file: `versionSyncTest` flags
|
|
9
|
+
* every line containing the marker string, so it must appear on the export
|
|
10
|
+
* line below and nowhere else — not even in prose.
|
|
11
|
+
*/
|
|
12
|
+
export const PACKAGE_NAME = 'office-outlook-mcp';
|
|
13
|
+
export const VERSION = '0.0.0'; // x-release-please-version
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response projections.
|
|
3
|
+
*
|
|
4
|
+
* Outlook's default projection is ~40 fields per message including the full
|
|
5
|
+
* body, which is a great deal of context for an agent that is browsing. Every
|
|
6
|
+
* read tool therefore takes `view` and defaults to `compact`.
|
|
7
|
+
*
|
|
8
|
+
* The projections below name only fields observed on live responses
|
|
9
|
+
* (2026-09-20). Anything unrecognised falls through to the raw value rather
|
|
10
|
+
* than being dropped — a record with holes in it is indistinguishable from
|
|
11
|
+
* "there was nothing there".
|
|
12
|
+
*/
|
|
13
|
+
import { projectOrRaw } from '@chrischall/mcp-utils';
|
|
14
|
+
function addr(r) {
|
|
15
|
+
const e = r?.EmailAddress;
|
|
16
|
+
if (!e)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (e.Name && e.Address && e.Name !== e.Address)
|
|
19
|
+
return `${e.Name} <${e.Address}>`;
|
|
20
|
+
return e.Address ?? e.Name;
|
|
21
|
+
}
|
|
22
|
+
function addrs(list) {
|
|
23
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
return list.map((r) => addr(r)).filter((s) => s !== undefined);
|
|
26
|
+
}
|
|
27
|
+
export function compactMessage(m) {
|
|
28
|
+
return pruned({
|
|
29
|
+
Id: m.Id,
|
|
30
|
+
Subject: m.Subject,
|
|
31
|
+
From: addr(m.From ?? m.Sender),
|
|
32
|
+
To: addrs(m.ToRecipients),
|
|
33
|
+
Received: m.ReceivedDateTime,
|
|
34
|
+
IsRead: m.IsRead,
|
|
35
|
+
HasAttachments: m.HasAttachments || undefined,
|
|
36
|
+
Preview: m.BodyPreview?.trim() || undefined,
|
|
37
|
+
// Present only when the caller asked for a single message; a listing
|
|
38
|
+
// projection would otherwise carry every body.
|
|
39
|
+
Body: m.Body?.Content,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function fullMessage(m) {
|
|
43
|
+
return pruned({
|
|
44
|
+
...compactMessage(m),
|
|
45
|
+
Cc: addrs(m.CcRecipients),
|
|
46
|
+
Sent: m.SentDateTime,
|
|
47
|
+
Importance: m.Importance,
|
|
48
|
+
IsDraft: m.IsDraft,
|
|
49
|
+
ConversationId: m.ConversationId,
|
|
50
|
+
ParentFolderId: m.ParentFolderId,
|
|
51
|
+
Categories: m.Categories?.length ? m.Categories : undefined,
|
|
52
|
+
WebLink: m.WebLink,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function compactEvent(e) {
|
|
56
|
+
return pruned({
|
|
57
|
+
Id: e.Id,
|
|
58
|
+
Subject: e.Subject,
|
|
59
|
+
Start: e.Start?.DateTime,
|
|
60
|
+
End: e.End?.DateTime,
|
|
61
|
+
TimeZone: e.Start?.TimeZone,
|
|
62
|
+
Location: e.Location?.DisplayName || undefined,
|
|
63
|
+
Organizer: addr(e.Organizer),
|
|
64
|
+
IsAllDay: e.IsAllDay || undefined,
|
|
65
|
+
IsCancelled: e.IsCancelled || undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function fullEvent(e) {
|
|
69
|
+
return pruned({
|
|
70
|
+
...compactEvent(e),
|
|
71
|
+
ShowAs: e.ShowAs,
|
|
72
|
+
Attendees: e.Attendees?.map((a) => pruned({ Who: addr(a), Response: a.Status?.Response })),
|
|
73
|
+
OnlineMeetingUrl: e.OnlineMeetingUrl,
|
|
74
|
+
Preview: e.BodyPreview?.trim() || undefined,
|
|
75
|
+
WebLink: e.WebLink,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function compactFolder(f) {
|
|
79
|
+
return pruned({
|
|
80
|
+
Id: f.Id,
|
|
81
|
+
Name: f.DisplayName,
|
|
82
|
+
Unread: f.UnreadItemCount,
|
|
83
|
+
Total: f.TotalItemCount,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Drop `undefined` so a compact record does not carry empty keys. */
|
|
87
|
+
function pruned(o) {
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const [k, v] of Object.entries(o)) {
|
|
90
|
+
if (v !== undefined)
|
|
91
|
+
out[k] = v;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Apply a projection to an OData collection envelope, preserving paging.
|
|
97
|
+
*
|
|
98
|
+
* `projectOrRaw` hands back the raw value when the projection throws or yields
|
|
99
|
+
* nothing, so a shape we did not anticipate degrades to "everything" rather
|
|
100
|
+
* than to "nothing".
|
|
101
|
+
*/
|
|
102
|
+
export function projectCollection(payload, project, label) {
|
|
103
|
+
const items = Array.isArray(payload?.value) ? payload.value : [];
|
|
104
|
+
return pruned({
|
|
105
|
+
count: items.length,
|
|
106
|
+
items: items.map((item) => projectOrRaw(item, project, { label, context: 'office-outlook-mcp' })),
|
|
107
|
+
nextLink: payload?.['@odata.nextLink'],
|
|
108
|
+
});
|
|
109
|
+
}
|
package/mint.yaml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
name: Outlook (Microsoft 365)
|
|
3
|
+
slug: outlook
|
|
4
|
+
summary: >-
|
|
5
|
+
Outlook / Microsoft 365 for Claude — read mail, folders, calendar, contacts
|
|
6
|
+
and tasks, and send mail with confirmation
|
|
7
|
+
#
|
|
8
|
+
# Hosting note (a comment, not user-facing summary text): this MCP mints its
|
|
9
|
+
# credential through the fetchproxy BROWSER BRIDGE. It is not a full-bridge
|
|
10
|
+
# server — every API request is a plain server-side fetch to
|
|
11
|
+
# outlook.office.com — but the bearer token it needs can only come from a
|
|
12
|
+
# signed-in Outlook tab, and tokens last only ~25h, so a hosted registration
|
|
13
|
+
# that is expected to keep working needs the bridge.
|
|
14
|
+
#
|
|
15
|
+
# A bridged registration also needs runtime `fly-shared`, `bridge: true` and a
|
|
16
|
+
# `bridgePortEnv`, which are REGISTRATION fields this manifest has no schema
|
|
17
|
+
# for — set them over the control API (PUT on the registration id). The port
|
|
18
|
+
# variable to name in `bridgePortEnv` is OUTLOOK_WS_PORT, declared below.
|
|
19
|
+
# `state.dataDir` below is required for `bridge`.
|
|
20
|
+
#
|
|
21
|
+
# The alternative, for a short-lived or CI registration, is to supply
|
|
22
|
+
# OUTLOOK_ACCESS_TOKEN as a principal secret and accept that it expires in
|
|
23
|
+
# about a day.
|
|
24
|
+
#
|
|
25
|
+
# Credentials here are PER PERSON — this reads someone's work mailbox — so a
|
|
26
|
+
# real deployment wants identity.perUserChild with OUTLOOK_ACCESS_TOKEN as an
|
|
27
|
+
# auth field, never one operator's token shared via --secret-env.
|
|
28
|
+
env:
|
|
29
|
+
- name: OUTLOOK_ACCESS_TOKEN
|
|
30
|
+
secret: true
|
|
31
|
+
required: false
|
|
32
|
+
help: >-
|
|
33
|
+
Pre-obtained Outlook bearer token. Overrides the browser bridge. Power
|
|
34
|
+
users / CI only; tokens last about 25 hours.
|
|
35
|
+
- name: OUTLOOK_DISABLE_FETCHPROXY
|
|
36
|
+
required: false
|
|
37
|
+
help: >-
|
|
38
|
+
Set to '1' to disable browser capture, requiring OUTLOOK_ACCESS_TOKEN.
|
|
39
|
+
- name: OUTLOOK_API_BASE
|
|
40
|
+
required: false
|
|
41
|
+
help: >-
|
|
42
|
+
Override the Outlook REST base URL. Defaults to
|
|
43
|
+
https://outlook.office.com/api/v2.0.
|
|
44
|
+
- name: OUTLOOK_WS_PORT
|
|
45
|
+
required: false
|
|
46
|
+
help: >-
|
|
47
|
+
fetchproxy concentrator port used for token capture. Defaults to 37149,
|
|
48
|
+
the fleet-wide shared port. Name this variable in the registration's
|
|
49
|
+
bridgePortEnv for a bridged deployment.
|
|
50
|
+
- name: OUTLOOK_CAPTURE_TIMEOUT
|
|
51
|
+
required: false
|
|
52
|
+
help: >-
|
|
53
|
+
Seconds to wait for the Outlook tab to make a request we can read a token
|
|
54
|
+
from. Defaults to 30.
|
|
55
|
+
- name: OUTLOOK_TOKEN_CACHE
|
|
56
|
+
required: false
|
|
57
|
+
help: >-
|
|
58
|
+
Set to 'false' to stop caching the captured token between runs. Defaults
|
|
59
|
+
to true.
|
|
60
|
+
- name: OUTLOOK_TOKEN_FILE
|
|
61
|
+
required: false
|
|
62
|
+
help: >-
|
|
63
|
+
Path to the cached token file. Defaults to
|
|
64
|
+
~/.office-outlook-mcp/token.json.
|
|
65
|
+
state:
|
|
66
|
+
dataDir: true
|
|
67
|
+
reason: >-
|
|
68
|
+
Caches the captured bearer token at ~/.office-outlook-mcp/token.json (0600)
|
|
69
|
+
and, for a bridged registration, holds the fetchproxy identity keypair
|
|
70
|
+
under ~/.fetchproxy/identity. Without persistence every cold start
|
|
71
|
+
re-captures from the browser — and on a scale-to-zero machine that means
|
|
72
|
+
re-prompting the user to approve pairing, constantly.
|
|
73
|
+
egress:
|
|
74
|
+
# The ONLY host this server fetches. The bridge's declared domains
|
|
75
|
+
# (cloud.microsoft, office.com) are what the BROWSER reaches during token
|
|
76
|
+
# capture, not what this Node process requests, so they are deliberately not
|
|
77
|
+
# listed here.
|
|
78
|
+
allow:
|
|
79
|
+
- outlook.office.com
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chrischall/office-outlook-mcp",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"mcpName": "io.github.chrischall/office-outlook-mcp",
|
|
5
|
+
"description": "Outlook / Microsoft 365 MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
6
|
+
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chrischall/office-outlook-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"claude",
|
|
19
|
+
"ai",
|
|
20
|
+
"outlook",
|
|
21
|
+
"office365",
|
|
22
|
+
"microsoft365",
|
|
23
|
+
"email",
|
|
24
|
+
"calendar"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"office-outlook-mcp": "dist/bundle.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
".claude-plugin",
|
|
33
|
+
".mcp.json",
|
|
34
|
+
"server.json",
|
|
35
|
+
"skills",
|
|
36
|
+
"mint.yaml"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc && npm run bundle",
|
|
40
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
|
|
41
|
+
"dev": "node dist/index.js",
|
|
42
|
+
"test": "npm run typecheck && vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.check.json"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@chrischall/mcp-utils": "^2.0.0",
|
|
49
|
+
"@fetchproxy/server": "^3.0.1",
|
|
50
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
51
|
+
"dotenv": "^18.0.1",
|
|
52
|
+
"zod": "^4.6.2"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@modelcontextprotocol/client": "^2.0.0",
|
|
56
|
+
"@types/node": "^26.0.0",
|
|
57
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
58
|
+
"esbuild": "^0.28.1",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vitest": "^5.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.chrischall/office-outlook-mcp",
|
|
4
|
+
"description": "Outlook / Microsoft 365 for Claude — mail, calendar, contacts and tasks, with confirm-gated sending",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/chrischall/office-outlook-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "@chrischall/office-outlook-mcp",
|
|
14
|
+
"version": "0.0.0",
|
|
15
|
+
"transport": { "type": "stdio" },
|
|
16
|
+
"environmentVariables": [
|
|
17
|
+
{
|
|
18
|
+
"name": "OUTLOOK_ACCESS_TOKEN",
|
|
19
|
+
"description": "Pre-obtained Outlook bearer token. Overrides the browser bridge. Power users / CI only; tokens last about 25 hours.",
|
|
20
|
+
"isRequired": false,
|
|
21
|
+
"format": "string",
|
|
22
|
+
"isSecret": true
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "OUTLOOK_DISABLE_FETCHPROXY",
|
|
26
|
+
"description": "Set to '1' to disable browser capture, requiring OUTLOOK_ACCESS_TOKEN.",
|
|
27
|
+
"isRequired": false,
|
|
28
|
+
"format": "string"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "OUTLOOK_API_BASE",
|
|
32
|
+
"description": "Override the Outlook REST base URL. Defaults to https://outlook.office.com/api/v2.0.",
|
|
33
|
+
"isRequired": false,
|
|
34
|
+
"format": "string"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "OUTLOOK_WS_PORT",
|
|
38
|
+
"description": "fetchproxy concentrator port used for token capture. Defaults to 37149, the fleet-wide shared port.",
|
|
39
|
+
"isRequired": false,
|
|
40
|
+
"format": "string"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "OUTLOOK_CAPTURE_TIMEOUT",
|
|
44
|
+
"description": "Seconds to wait for the Outlook tab to make a request we can read a token from. Defaults to 30.",
|
|
45
|
+
"isRequired": false,
|
|
46
|
+
"format": "string"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "OUTLOOK_TOKEN_CACHE",
|
|
50
|
+
"description": "Set to 'false' to stop caching the captured token between runs. Defaults to true.",
|
|
51
|
+
"isRequired": false,
|
|
52
|
+
"format": "string"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "OUTLOOK_TOKEN_FILE",
|
|
56
|
+
"description": "Path to the cached token file. Defaults to ~/.office-outlook-mcp/token.json.",
|
|
57
|
+
"isRequired": false,
|
|
58
|
+
"format": "string"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|