@chrischall/office-outlook-mcp 0.8.2 → 0.8.4
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -1
- package/dist/bundle.js +2226 -1741
- package/dist/tools/_paging.js +19 -5
- package/dist/tools/export.js +29 -11
- package/dist/tools/mail.js +90 -19
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/tools/_paging.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
4
|
import { stripOData } from '../view.js';
|
|
@@ -38,6 +39,19 @@ export function plainCollection(data) {
|
|
|
38
39
|
out.nextLink = next;
|
|
39
40
|
return out;
|
|
40
41
|
}
|
|
42
|
+
/** An Id this short is kept as is (a key, or a test fixture); a real Outlook Id is ~150 characters. */
|
|
43
|
+
const MAX_KEY_LENGTH = 16;
|
|
44
|
+
/**
|
|
45
|
+
* The key a position keeps for a message Id: the Id itself when short, else
|
|
46
|
+
* 12 base64url characters (72 bits) of its SHA-256. Keys only tell apart the
|
|
47
|
+
* messages tied at ONE instant, so a collision is not a practical concern,
|
|
48
|
+
* and a burst of messages in one second costs ~15 characters each in the token
|
|
49
|
+
* rather than a whole Id. A token from an earlier release, carrying whole Ids,
|
|
50
|
+
* is re-keyed on read.
|
|
51
|
+
*/
|
|
52
|
+
export function idKey(id) {
|
|
53
|
+
return id.length <= MAX_KEY_LENGTH ? id : createHash('sha256').update(id).digest('base64url').slice(0, 12);
|
|
54
|
+
}
|
|
41
55
|
export const fromParam = z
|
|
42
56
|
.string()
|
|
43
57
|
.min(1)
|
|
@@ -104,7 +118,7 @@ export function decodeFrom(from) {
|
|
|
104
118
|
!p.ids.every((id) => typeof id === 'string')) {
|
|
105
119
|
throw new McpToolError('`from` is neither an ISO 8601 time nor a next_from token.', { hint: FROM_HINT });
|
|
106
120
|
}
|
|
107
|
-
return { t: utcLiteral(ms), ids: p.ids };
|
|
121
|
+
return { t: utcLiteral(ms), ids: [...new Set(p.ids.map(idKey))] };
|
|
108
122
|
}
|
|
109
123
|
/**
|
|
110
124
|
* The `$filter` date bound and `$top` for a page from `pos`. The bound is
|
|
@@ -121,21 +135,21 @@ export function fromQuery(pos, limit) {
|
|
|
121
135
|
* or the API has another page.
|
|
122
136
|
*/
|
|
123
137
|
export function fromPage(rows, pos, limit, apiHasMore) {
|
|
124
|
-
const seen = new Set(pos.ids);
|
|
125
|
-
const fresh = rows.filter((r) => r.Id === undefined || !seen.has(r.Id));
|
|
138
|
+
const seen = new Set(pos.ids.map(idKey));
|
|
139
|
+
const fresh = rows.filter((r) => r.Id === undefined || !seen.has(idKey(r.Id)));
|
|
126
140
|
const kept = fresh.slice(0, limit);
|
|
127
141
|
const next = nextPosition(kept, pos);
|
|
128
142
|
return { rows: kept, next_from: encodeFrom(next), has_more: fresh.length > limit || apiHasMore };
|
|
129
143
|
}
|
|
130
144
|
function nextPosition(rows, pos) {
|
|
131
145
|
const dated = rows
|
|
132
|
-
.map((r) => ({ id: r.Id, ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN }))
|
|
146
|
+
.map((r) => ({ id: r.Id === undefined ? undefined : idKey(r.Id), ms: r.ReceivedDateTime ? Date.parse(r.ReceivedDateTime) : NaN }))
|
|
133
147
|
.filter((r) => r.id !== undefined && !Number.isNaN(r.ms));
|
|
134
148
|
if (dated.length === 0)
|
|
135
149
|
return pos;
|
|
136
150
|
const newest = Math.max(...dated.map((r) => r.ms));
|
|
137
151
|
const tied = dated.filter((r) => r.ms === newest).map((r) => r.id);
|
|
138
152
|
// The position did not move: earlier ties at this instant stay excluded.
|
|
139
|
-
const carried = newest === Date.parse(pos.t) ? pos.ids.filter((id) => !tied.includes(id)) : [];
|
|
153
|
+
const carried = newest === Date.parse(pos.t) ? pos.ids.map(idKey).filter((id) => !tied.includes(id)) : [];
|
|
140
154
|
return { t: utcLiteral(newest), ids: [...carried, ...tied] };
|
|
141
155
|
}
|
package/dist/tools/export.js
CHANGED
|
@@ -158,8 +158,15 @@ const eventArgsSchema = z.object({
|
|
|
158
158
|
format: z.enum(['jsonl', 'markdown']),
|
|
159
159
|
attachments: z.boolean().optional(),
|
|
160
160
|
});
|
|
161
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Where the next part starts: a keyset position, or a nextLink page + rows
|
|
163
|
+
* consumed from it. The keyset position is kept as plain `{ t, ids }` (`at`):
|
|
164
|
+
* the cursor is base64'd as a whole, so a nested `next_from` token would be
|
|
165
|
+
* encoded twice. `from` (that nested token) is still read from cursors an
|
|
166
|
+
* earlier release handed out.
|
|
167
|
+
*/
|
|
162
168
|
const positionSchema = z.union([
|
|
169
|
+
z.object({ at: z.object({ t: z.string().min(1), ids: z.array(z.string()) }) }),
|
|
163
170
|
z.object({ from: z.string().min(1) }),
|
|
164
171
|
z.object({ link: z.string().min(1).optional(), offset: z.number().int().min(0).max(MAX_TOP) }),
|
|
165
172
|
]);
|
|
@@ -877,12 +884,20 @@ export function registerExportTools(server, client, opts = {}) {
|
|
|
877
884
|
const folder = args.search !== undefined ? 'all' : (args.folder ?? 'inbox');
|
|
878
885
|
const path = folder === 'all' ? '/me/messages' : `/me/mailfolders/${encodeURIComponent(folder)}/messages`;
|
|
879
886
|
const perPage = args.attachments === true ? Math.min(pageSize, ATTACHMENT_PAGE_SIZE) : pageSize;
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
887
|
+
const stored = resumed?.pos;
|
|
888
|
+
// Validate (and normalise) a cursor's keyset position before any work; read an older cursor's nested token.
|
|
889
|
+
const pos = stored === undefined
|
|
890
|
+
? args.search !== undefined
|
|
891
|
+
? { offset: 0 }
|
|
892
|
+
: { at: { t: since ?? EPOCH, ids: [] } }
|
|
893
|
+
: 'from' in stored
|
|
894
|
+
? { at: decodeFrom(stored.from) }
|
|
895
|
+
: 'at' in stored
|
|
896
|
+
? { at: decodeFrom(encodeFrom(stored.at)) }
|
|
897
|
+
: stored;
|
|
883
898
|
const fetch = async (p, remaining) => {
|
|
884
|
-
if ('
|
|
885
|
-
const at =
|
|
899
|
+
if ('at' in p) {
|
|
900
|
+
const { at } = p;
|
|
886
901
|
const limit = Math.min(remaining, perPage);
|
|
887
902
|
const { bound, top } = fromQuery(at, limit);
|
|
888
903
|
const filter = until ? `${bound} and ReceivedDateTime lt ${until}` : bound;
|
|
@@ -900,7 +915,7 @@ export function registerExportTools(server, client, opts = {}) {
|
|
|
900
915
|
posAfter: () => p,
|
|
901
916
|
};
|
|
902
917
|
}
|
|
903
|
-
if (page.has_more && page.next_from ===
|
|
918
|
+
if (page.has_more && page.next_from === encodeFrom(at)) {
|
|
904
919
|
// No row carried a usable ReceivedDateTime, so the position did not
|
|
905
920
|
// move; asking again would return (and duplicate) the same page forever.
|
|
906
921
|
return {
|
|
@@ -913,10 +928,12 @@ export function registerExportTools(server, client, opts = {}) {
|
|
|
913
928
|
}
|
|
914
929
|
return {
|
|
915
930
|
rows: page.rows,
|
|
916
|
-
pos: {
|
|
931
|
+
pos: { at: decodeFrom(page.next_from) },
|
|
917
932
|
hasMore: page.has_more,
|
|
918
933
|
// The first n rows are fresh and ascending, so the keyset position after them is computed the same way.
|
|
919
|
-
posAfter: (n) => n >= page.rows.length
|
|
934
|
+
posAfter: (n) => n >= page.rows.length
|
|
935
|
+
? { at: decodeFrom(page.next_from) }
|
|
936
|
+
: { at: decodeFrom(fromPage(page.rows.slice(0, n), at, n, true).next_from) },
|
|
920
937
|
};
|
|
921
938
|
}
|
|
922
939
|
return linkBatch(p, remaining, () => with429Retry(() => client.get(path, {
|
|
@@ -1010,9 +1027,10 @@ export function registerExportTools(server, client, opts = {}) {
|
|
|
1010
1027
|
}
|
|
1011
1028
|
const prefer = args.time_zone ? `outlook.timezone="${args.time_zone}"` : undefined;
|
|
1012
1029
|
const path = args.calendar !== undefined ? `/me/calendars/${encodeURIComponent(args.calendar)}/calendarview` : '/me/calendarview';
|
|
1013
|
-
const
|
|
1014
|
-
if ('
|
|
1030
|
+
const stored = resumed?.pos ?? { offset: 0 };
|
|
1031
|
+
if (!('offset' in stored))
|
|
1015
1032
|
throw new McpToolError('`cursor` is not a valid export cursor for this tool.');
|
|
1033
|
+
const pos = stored;
|
|
1016
1034
|
const fetch = (p, remaining) => linkBatch(p, remaining, () => with429Retry(() => client.get(path, {
|
|
1017
1035
|
text: true,
|
|
1018
1036
|
...(prefer ? { prefer } : {}),
|
package/dist/tools/mail.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
5
|
+
import { expandPath, mapWithConcurrency, readEnvVar, minifiedResult, resolveView, viewParam, McpToolError, } from '@chrischall/mcp-utils';
|
|
6
|
+
import { blobStoreFromEnv, deliverFile, isHostedEnv } from '@chrischall/mcp-utils/export';
|
|
3
7
|
import { briefDate, briefLine, briefResult, spaced, workMeta } from './_hints.js';
|
|
4
8
|
import { addr, addrs, cappedRecipients, compactFolder, compactMessage, fullMessage, projectCollection, VIEWS, } from '../view.js';
|
|
5
9
|
import { EVENT_EXPAND, INVITE_GONE, MEETING_MESSAGE_TYPE, resolvedEvent } from './_invite.js';
|
|
@@ -111,6 +115,30 @@ function stripUndefined(o) {
|
|
|
111
115
|
export const ATTACHMENT_META_SELECT = 'Id,Name,ContentType,Size,IsInline';
|
|
112
116
|
/** Above this an attachment is never inlined, whatever its type (base64 inflates it ~4/3 again). */
|
|
113
117
|
const MAX_INLINE_BYTES = 5 * 1024 * 1024;
|
|
118
|
+
/**
|
|
119
|
+
* Largest attachment whose bytes are downloaded to hand over as a link or a
|
|
120
|
+
* saved file (the export tool's per-file cap too). Outlook returns them as one
|
|
121
|
+
* base64 JSON string, so this bounds the memory one call can take.
|
|
122
|
+
*/
|
|
123
|
+
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
124
|
+
const SERVER_NAME = 'office-outlook-mcp';
|
|
125
|
+
const OUTPUT_DIR_ENV = 'OUTLOOK_OUTPUT_DIR';
|
|
126
|
+
function deliveryRoutes(env) {
|
|
127
|
+
const store = blobStoreFromEnv(env);
|
|
128
|
+
if (isHostedEnv(env))
|
|
129
|
+
return { hosted: true, ...(store ? { store } : {}) };
|
|
130
|
+
const configured = readEnvVar(OUTPUT_DIR_ENV, { env });
|
|
131
|
+
if (configured !== undefined)
|
|
132
|
+
return { hosted: false, diskDir: expandPath(configured) };
|
|
133
|
+
const downloads = join(readEnvVar('HOME', { env }) ?? homedir(), 'Downloads');
|
|
134
|
+
return { hosted: false, ...(existsSync(downloads) ? { diskDir: join(downloads, SERVER_NAME) } : {}) };
|
|
135
|
+
}
|
|
136
|
+
/** Why an attachment's bytes have no way back to the caller here. */
|
|
137
|
+
function noRouteHint(routes, why) {
|
|
138
|
+
return routes.hosted
|
|
139
|
+
? `${why} This hosted server has no blob store configured (MCP_BLOB_BASE_URL / MCP_BLOB_SIGNING_KEY), so it cannot hand over a download link. Open it in Outlook to read it.`
|
|
140
|
+
: `${why} Set ${OUTPUT_DIR_ENV} (or create ~/Downloads) to have it saved to disk, or open it in Outlook.`;
|
|
141
|
+
}
|
|
114
142
|
/** Decoded text is cut here; a longer file is marked `textTruncated`. */
|
|
115
143
|
const MAX_TEXT_BYTES = 200 * 1024;
|
|
116
144
|
/**
|
|
@@ -192,7 +220,8 @@ async function listFrom(client, pos, opts) {
|
|
|
192
220
|
: projectCollection({ value: page.rows }, opts.view === 'full' ? fullMessage : compactMessage, 'message');
|
|
193
221
|
return mailboxUntrusted({ ...body, next_from: page.next_from, has_more: page.has_more });
|
|
194
222
|
}
|
|
195
|
-
export function registerMailTools(server, client) {
|
|
223
|
+
export function registerMailTools(server, client, opts = {}) {
|
|
224
|
+
const env = opts.env ?? process.env;
|
|
196
225
|
server.registerTool('outlook_list_folders', {
|
|
197
226
|
description: 'List mail folders with unread and total counts. Use this to discover folder ids before listing messages; the well-known names (' +
|
|
198
227
|
WELL_KNOWN.join(', ') +
|
|
@@ -433,7 +462,7 @@ export function registerMailTools(server, client) {
|
|
|
433
462
|
return minifiedResult(plainCollection(data));
|
|
434
463
|
});
|
|
435
464
|
server.registerTool('outlook_get_attachment', {
|
|
436
|
-
description: "Get one attachment's content (ids from outlook_list_attachments).
|
|
465
|
+
description: "Get one attachment's content (ids from outlook_list_attachments). A text-like file (text/*, JSON, CSV, XML, .ics) up to 5 MB is decoded to `text`, capped at 200 KB (`textTruncated` marks a cut). An image up to 5 MB comes back as image content the model can see. Anything else (PDF, Office, archives) or larger, up to 50 MB, is handed over as the file itself: on a hosted server a signed, expiring download link (`downloadUrl`, valid until `downloadExpiresAt`) — give it to the user; on a local install a fresh file (`path`) under OUTLOOK_OUTPUT_DIR, else ~/Downloads/office-outlook-mcp. `via` says which (image, resource, link or disk). Where neither route exists the result is metadata only and `hint` says why. An attached Outlook item (a forwarded email or event) or a cloud-file link has no bytes to return; the result says which." +
|
|
437
466
|
' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
438
467
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
439
468
|
_meta: workMeta('lookup'),
|
|
@@ -469,12 +498,23 @@ export function registerMailTools(server, client) {
|
|
|
469
498
|
return mailboxUntrusted(out);
|
|
470
499
|
}
|
|
471
500
|
const image = isImage(contentType);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
501
|
+
const text = !image && isText(contentType, name);
|
|
502
|
+
const size = meta.Size ?? 0;
|
|
503
|
+
const routes = deliveryRoutes(env);
|
|
504
|
+
const canHandOver = routes.store !== undefined || routes.diskDir !== undefined;
|
|
505
|
+
// Decide before downloading, so a 25 MB PDF is never fetched only to be dropped.
|
|
506
|
+
if (!canHandOver) {
|
|
507
|
+
if (!image && !text) {
|
|
508
|
+
out.hint = noRouteHint(routes, 'Binary content is not inlined: only images and text-like files are returned.');
|
|
509
|
+
return mailboxUntrusted(out);
|
|
510
|
+
}
|
|
511
|
+
if (size > MAX_INLINE_BYTES) {
|
|
512
|
+
out.hint = noRouteHint(routes, 'Content is not inlined above 5 MB.');
|
|
513
|
+
return mailboxUntrusted(out);
|
|
514
|
+
}
|
|
475
515
|
}
|
|
476
|
-
if (
|
|
477
|
-
out.hint = '
|
|
516
|
+
if (size > MAX_DOWNLOAD_BYTES) {
|
|
517
|
+
out.hint = 'Attachments over 50 MB are not downloaded. Open it in Outlook to read it.';
|
|
478
518
|
return mailboxUntrusted(out);
|
|
479
519
|
}
|
|
480
520
|
const full = await client.get(path);
|
|
@@ -485,20 +525,51 @@ export function registerMailTools(server, client) {
|
|
|
485
525
|
}
|
|
486
526
|
// Size is Outlook's figure for the whole record and can understate the
|
|
487
527
|
// file; the base64 length bounds the real size, so judge it before decoding.
|
|
488
|
-
|
|
489
|
-
|
|
528
|
+
const decodedSize = base64DecodedLength(bytes);
|
|
529
|
+
if (decodedSize > MAX_DOWNLOAD_BYTES) {
|
|
530
|
+
out.hint = 'Attachments over 50 MB are not downloaded. Open it in Outlook to read it.';
|
|
531
|
+
return mailboxUntrusted(out);
|
|
532
|
+
}
|
|
533
|
+
if (decodedSize > MAX_INLINE_BYTES && !canHandOver) {
|
|
534
|
+
out.hint = noRouteHint(routes, 'Content is not inlined above 5 MB.');
|
|
490
535
|
return mailboxUntrusted(out);
|
|
491
536
|
}
|
|
492
537
|
const buf = Buffer.from(bytes, 'base64');
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
538
|
+
if (text && buf.length <= MAX_INLINE_BYTES) {
|
|
539
|
+
out.inlined = true;
|
|
540
|
+
out.text = buf.subarray(0, utf8Boundary(buf, MAX_TEXT_BYTES)).toString('utf8');
|
|
541
|
+
if (buf.length > MAX_TEXT_BYTES)
|
|
542
|
+
out.textTruncated = true;
|
|
543
|
+
return mailboxUntrusted(out);
|
|
498
544
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
545
|
+
// The bytes themselves: an image inline when it fits; otherwise a signed
|
|
546
|
+
// link (hosted) or a fresh 0600 file in the download directory (local).
|
|
547
|
+
const delivered = await deliverFile({
|
|
548
|
+
// A standalone copy: a small Buffer shares Node's allocation pool, and
|
|
549
|
+
// mcp-utils' blob PUT (3.7.1) sends `view.slice().buffer` — for a Buffer
|
|
550
|
+
// that is the WHOLE pool, i.e. unrelated process memory.
|
|
551
|
+
bytes: new Uint8Array(buf),
|
|
552
|
+
fileName: name || 'attachment',
|
|
553
|
+
...(contentType ? { mimeType: contentType } : {}),
|
|
554
|
+
store: routes.store ?? null,
|
|
555
|
+
keyPrefix: 'attachments',
|
|
556
|
+
inlineMaxBytes: MAX_INLINE_BYTES,
|
|
557
|
+
// Locally a non-image is saved rather than inlined as an embedded resource.
|
|
558
|
+
prefer: image || routes.store !== undefined ? 'auto' : 'disk',
|
|
559
|
+
...(routes.diskDir !== undefined ? { disk: { dir: routes.diskDir, mode: 0o600 } } : {}),
|
|
560
|
+
});
|
|
561
|
+
out.inlined = delivered.via === 'image' || delivered.via === 'resource';
|
|
562
|
+
out.via = delivered.via;
|
|
563
|
+
if (delivered.url !== undefined)
|
|
564
|
+
out.downloadUrl = delivered.url;
|
|
565
|
+
if (delivered.expiresAt !== undefined)
|
|
566
|
+
out.downloadExpiresAt = delivered.expiresAt;
|
|
567
|
+
if (delivered.path !== undefined)
|
|
568
|
+
out.path = delivered.path;
|
|
569
|
+
if (delivered.note !== undefined)
|
|
570
|
+
out.deliveryNote = delivered.note;
|
|
571
|
+
// The name is the sender's text, so the metadata keeps its envelope; any inline bytes follow it.
|
|
572
|
+
const result = mailboxUntrusted(out);
|
|
573
|
+
return { ...result, content: [...result.content, ...delivered.content] };
|
|
503
574
|
});
|
|
504
575
|
}
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/office-outlook-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"mcpName": "io.github.chrischall/office-outlook-mcp",
|
|
5
5
|
"description": "Outlook / Microsoft 365 MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.check.json"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^3.
|
|
48
|
+
"@chrischall/mcp-utils": "^3.7.2",
|
|
49
49
|
"@fetchproxy/server": "^3.6.2",
|
|
50
50
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
51
51
|
"dotenv": "^18.0.1",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/office-outlook-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.8.
|
|
9
|
+
"version": "0.8.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/office-outlook-mcp",
|
|
14
|
-
"version": "0.8.
|
|
14
|
+
"version": "0.8.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|