@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.
@@ -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
  }
@@ -158,8 +158,15 @@ const eventArgsSchema = z.object({
158
158
  format: z.enum(['jsonl', 'markdown']),
159
159
  attachments: z.boolean().optional(),
160
160
  });
161
- /** Where the next part starts: a keyset position, or a nextLink page + rows consumed from it. */
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 pos = resumed?.pos ?? (args.search !== undefined ? { offset: 0 } : { from: encodeFrom({ t: since ?? EPOCH, ids: [] }) });
881
- if ('from' in pos)
882
- decodeFrom(pos.from); // validate a cursor's position before any work
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 ('from' in p) {
885
- const at = decodeFrom(p.from);
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 === p.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: { from: page.next_from },
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 ? { from: page.next_from } : { from: fromPage(page.rows.slice(0, n), at, n, true).next_from },
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 pos = resumed?.pos ?? { offset: 0 };
1014
- if ('from' in pos)
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 } : {}),
@@ -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 { imageResult, mapWithConcurrency, minifiedResult, resolveView, viewParam, McpToolError, } from '@chrischall/mcp-utils';
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). An image comes back as image content the model can see; a text-like file (text/*, JSON, CSV, XML, .ics) is decoded to `text`, capped at 200 KB (`textTruncated` marks a cut). Any other binary (PDF, Office, archives) and anything over 5 MB is returned as metadata only — its bytes are not inlined. An attached Outlook item (a forwarded email or event) or a cloud-file link has no bytes to return; the result says which." +
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
- if (!image && !isText(contentType, name)) {
473
- out.hint = 'Binary content is not inlined: only images and text-like files are returned. Open it in Outlook to read it.';
474
- return mailboxUntrusted(out);
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 ((meta.Size ?? 0) > MAX_INLINE_BYTES) {
477
- out.hint = 'Content is not inlined above 5 MB. Open it in Outlook to read it.';
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
- if (base64DecodedLength(bytes) > MAX_INLINE_BYTES) {
489
- out.hint = 'Content is not inlined above 5 MB. Open it in Outlook to read it.';
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
- out.inlined = true;
494
- if (image) {
495
- // The name is the sender's text, so the metadata keeps its envelope; the image follows it.
496
- const env = mailboxUntrusted(out);
497
- return { ...env, content: [...env.content, ...imageResult(bytes, contentType).content] };
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
- out.text = buf.subarray(0, utf8Boundary(buf, MAX_TEXT_BYTES)).toString('utf8');
500
- if (buf.length > MAX_TEXT_BYTES)
501
- out.textTruncated = true;
502
- return mailboxUntrusted(out);
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
@@ -10,4 +10,4 @@
10
10
  * line below and nowhere else — not even in prose.
11
11
  */
12
12
  export const PACKAGE_NAME = 'office-outlook-mcp';
13
- export const VERSION = '0.8.2'; // x-release-please-version
13
+ export const VERSION = '0.8.4'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.8.2",
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.3.1",
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.2",
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.2",
14
+ "version": "0.8.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },