@chrischall/office-outlook-mcp 0.1.5 → 0.2.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Outlook / Microsoft 365 — mail, calendar, contacts and tasks via natural language",
10
- "version": "0.1.5"
10
+ "version": "0.2.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Outlook (Microsoft 365)",
16
16
  "source": "./",
17
17
  "description": "MCP server for Outlook / Microsoft 365 — read mail, folders, calendar, contacts and tasks, and send mail with confirmation",
18
- "version": "0.1.5",
18
+ "version": "0.2.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "office-outlook-mcp",
3
3
  "displayName": "Outlook (Microsoft 365)",
4
- "version": "0.1.5",
4
+ "version": "0.2.0",
5
5
  "description": "Outlook / Microsoft 365 for Claude — read mail, folders, calendar, contacts and tasks, and send mail with confirmation, using your signed-in browser session",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -36027,7 +36027,7 @@ var init_version = __esm({
36027
36027
  "src/version.ts"() {
36028
36028
  "use strict";
36029
36029
  PACKAGE_NAME = "office-outlook-mcp";
36030
- VERSION = "0.1.5";
36030
+ VERSION = "0.2.0";
36031
36031
  }
36032
36032
  });
36033
36033
 
@@ -46223,17 +46223,33 @@ var OutlookClient = class {
46223
46223
  headers: prefer.length ? { Prefer: prefer.join(", ") } : void 0
46224
46224
  });
46225
46225
  }
46226
- /** GET an absolute URL — used only to follow `@odata.nextLink`. */
46227
- async getAbsolute(url2) {
46226
+ /**
46227
+ * GET an absolute URL — used only to follow `@odata.nextLink`.
46228
+ *
46229
+ * The link is absolute and already carries the base path
46230
+ * (`https://outlook.office.com/api/v2.0/me/messages?$skip=25`), while the
46231
+ * underlying client appends every path to that same base. So the base path is
46232
+ * stripped here, and a link that is not under it — another origin, or another
46233
+ * path on the same host — is refused rather than sent the bearer token.
46234
+ */
46235
+ async getAbsolute(url2, opts = {}) {
46228
46236
  this.#requireConfigured();
46229
46237
  const base = new URL(this.#baseUrl);
46230
- const target = new URL(url2);
46231
- if (target.origin !== base.origin) {
46232
- throw new McpToolError(`Refusing to follow a link to ${target.origin}.`, {
46233
- hint: "Pagination links must stay on the Outlook API host."
46238
+ let target;
46239
+ try {
46240
+ target = new URL(url2);
46241
+ } catch {
46242
+ throw new McpToolError(`Refusing to follow a link that is not a URL: ${url2}`, {
46243
+ hint: "Pass the nextLink value exactly as a previous listing returned it."
46244
+ });
46245
+ }
46246
+ const prefix = base.pathname.replace(/\/+$/, "");
46247
+ if (target.origin !== base.origin || !(target.pathname === prefix || target.pathname.startsWith(`${prefix}/`))) {
46248
+ throw new McpToolError(`Refusing to follow a link to ${target.origin}${target.pathname}.`, {
46249
+ hint: `Pagination links must stay under the Outlook API base (${this.#baseUrl}).`
46234
46250
  });
46235
46251
  }
46236
- return this.#client().fetchJson("GET", target.pathname + target.search);
46252
+ return this.get(target.pathname.slice(prefix.length) + target.search, opts);
46237
46253
  }
46238
46254
  /**
46239
46255
  * The single write path. Everything mutating goes through here so auth and
@@ -46373,6 +46389,22 @@ function projectCollection(payload, project, label) {
46373
46389
  });
46374
46390
  }
46375
46391
 
46392
+ // src/tools/_paging.ts
46393
+ init_zod();
46394
+ var nextLinkParam = external_exports.string().min(1).optional().describe(
46395
+ "Continue a previous listing: pass its `nextLink` value unchanged to fetch the next page. When set, the paging/filter arguments are taken from the link and ignored here."
46396
+ );
46397
+ function fetchPage(client2, nextLink, path, opts = {}) {
46398
+ return nextLink !== void 0 ? client2.getAbsolute(nextLink, opts) : client2.get(path, opts);
46399
+ }
46400
+ function plainCollection(data) {
46401
+ const items = Array.isArray(data?.value) ? data.value : [];
46402
+ const out = { count: items.length, items: items.map(stripOData) };
46403
+ const next = data?.["@odata.nextLink"];
46404
+ if (next) out.nextLink = next;
46405
+ return out;
46406
+ }
46407
+
46376
46408
  // src/tools/mail.ts
46377
46409
  var VIEWS2 = ["compact", "full", "raw"];
46378
46410
  var WELL_KNOWN = [
@@ -46405,11 +46437,14 @@ function registerMailTools(server, client2) {
46405
46437
  annotations: { readOnlyHint: true },
46406
46438
  inputSchema: external_exports.object({
46407
46439
  view: viewParam(VIEWS2),
46408
- limit: external_exports.number().int().min(1).max(200).optional().describe("Max folders (default 50)")
46440
+ limit: external_exports.number().int().min(1).max(200).optional().describe("Max folders (default 50)"),
46441
+ nextLink: nextLinkParam
46409
46442
  })
46410
46443
  },
46411
- async ({ view, limit }) => {
46412
- const data = await client2.get(
46444
+ async ({ view, limit, nextLink }) => {
46445
+ const data = await fetchPage(
46446
+ client2,
46447
+ nextLink,
46413
46448
  `/me/mailfolders${qs({ $top: limit ?? 50 })}`
46414
46449
  );
46415
46450
  if (resolveView(view, VIEWS2) === "raw") return minifiedResult(data);
@@ -46419,7 +46454,7 @@ function registerMailTools(server, client2) {
46419
46454
  server.registerTool(
46420
46455
  "outlook_list_messages",
46421
46456
  {
46422
- description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included \u2014 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.",
46457
+ description: "List messages in a folder, newest first. Defaults to the inbox. Use `unreadOnly` for a triage view. Bodies are NOT included \u2014 call outlook_get_message for one. Note `search` and `unreadOnly` cannot be combined on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless.",
46423
46458
  annotations: { readOnlyHint: true },
46424
46459
  inputSchema: external_exports.object({
46425
46460
  view: viewParam(VIEWS2),
@@ -46427,11 +46462,12 @@ function registerMailTools(server, client2) {
46427
46462
  limit: external_exports.number().int().min(1).max(100).optional().describe("Max messages (default 25)"),
46428
46463
  skip: external_exports.number().int().min(0).optional().describe("Offset for paging"),
46429
46464
  unreadOnly: external_exports.boolean().optional().describe("Only unread messages"),
46430
- search: external_exports.string().optional().describe("Full-text search across the whole mailbox; cannot combine with unreadOnly")
46465
+ search: external_exports.string().optional().describe("Full-text search across the whole mailbox; cannot combine with unreadOnly on a first page (ignored with nextLink)"),
46466
+ nextLink: nextLinkParam
46431
46467
  })
46432
46468
  },
46433
- async ({ view, folder, limit, skip, unreadOnly, search }) => {
46434
- if (search !== void 0 && unreadOnly === true) {
46469
+ async ({ view, folder, limit, skip, unreadOnly, search, nextLink }) => {
46470
+ if (nextLink === void 0 && search !== void 0 && unreadOnly === true) {
46435
46471
  throw new McpToolError("`search` and `unreadOnly` cannot be combined.", {
46436
46472
  hint: "Outlook rejects $search together with $filter. Search first, then filter the results."
46437
46473
  });
@@ -46448,7 +46484,11 @@ function registerMailTools(server, client2) {
46448
46484
  params.$orderby = "ReceivedDateTime desc";
46449
46485
  if (unreadOnly === true) params.$filter = "IsRead eq false";
46450
46486
  }
46451
- const data = await client2.get(`${base}${qs(params)}`);
46487
+ const data = await fetchPage(
46488
+ client2,
46489
+ nextLink,
46490
+ `${base}${qs(params)}`
46491
+ );
46452
46492
  const v = resolveView(view, VIEWS2);
46453
46493
  if (v === "raw") return minifiedResult(data);
46454
46494
  return minifiedResult(
@@ -46482,19 +46522,19 @@ function registerMailTools(server, client2) {
46482
46522
  description: "List a message's attachments (name, size, content type). Metadata only \u2014 this deliberately does not download bytes.",
46483
46523
  annotations: { readOnlyHint: true },
46484
46524
  inputSchema: external_exports.object({
46485
- id: external_exports.string().min(1).describe("Message Id")
46525
+ id: external_exports.string().min(1).describe("Message Id"),
46526
+ nextLink: nextLinkParam
46486
46527
  })
46487
46528
  },
46488
- async ({ id }) => {
46489
- const data = await client2.get(
46529
+ async ({ id, nextLink }) => {
46530
+ const data = await fetchPage(
46531
+ client2,
46532
+ nextLink,
46490
46533
  `/me/messages/${encodeURIComponent(id)}/attachments${qs({
46491
46534
  $select: "Id,Name,Size,ContentType"
46492
46535
  })}`
46493
46536
  );
46494
- return minifiedResult({
46495
- count: data.value?.length ?? 0,
46496
- items: (data.value ?? []).map(stripOData)
46497
- });
46537
+ return minifiedResult(plainCollection(data));
46498
46538
  }
46499
46539
  );
46500
46540
  }
@@ -46541,12 +46581,15 @@ function registerCalendarTools(server, client2) {
46541
46581
  timeZone: external_exports.string().optional().describe(
46542
46582
  'Windows time-zone name for the returned times, e.g. "Eastern Standard Time". NOT an IANA name like America/New_York.'
46543
46583
  ),
46544
- limit: external_exports.number().int().min(1).max(200).optional().describe("Max events (default 50)")
46584
+ limit: external_exports.number().int().min(1).max(200).optional().describe("Max events (default 50)"),
46585
+ nextLink: nextLinkParam
46545
46586
  })
46546
46587
  },
46547
- async ({ view, start, end, timeZone, limit }) => {
46588
+ async ({ view, start, end, timeZone, limit, nextLink }) => {
46548
46589
  const zone = timeZone ?? await mailboxTimeZone(client2);
46549
- const data = await client2.get(
46590
+ const data = await fetchPage(
46591
+ client2,
46592
+ nextLink,
46550
46593
  `/me/calendarview${qs2({
46551
46594
  startDateTime: start,
46552
46595
  endDateTime: end,
@@ -46591,18 +46634,14 @@ function registerCalendarTools(server, client2) {
46591
46634
  description: "List the calendars in the mailbox, for picking one to read or write against.",
46592
46635
  annotations: { readOnlyHint: true },
46593
46636
  inputSchema: external_exports.object({
46594
- view: viewParam(VIEWS2)
46637
+ view: viewParam(VIEWS2),
46638
+ nextLink: nextLinkParam
46595
46639
  })
46596
46640
  },
46597
- async ({ view }) => {
46598
- const data = await client2.get(
46599
- `/me/calendars${qs2({ $select: "Id,Name,Color,CanEdit,Owner" })}`
46600
- );
46641
+ async ({ view, nextLink }) => {
46642
+ const data = await fetchPage(client2, nextLink, `/me/calendars${qs2({ $select: "Id,Name,Color,CanEdit,Owner" })}`);
46601
46643
  if (resolveView(view, VIEWS2) === "raw") return minifiedResult(data);
46602
- return minifiedResult({
46603
- count: data.value?.length ?? 0,
46604
- items: (data.value ?? []).map(stripOData)
46605
- });
46644
+ return minifiedResult(plainCollection(data));
46606
46645
  }
46607
46646
  );
46608
46647
  }
@@ -46620,7 +46659,7 @@ function qs3(params) {
46620
46659
  }
46621
46660
  function collection(data, raw) {
46622
46661
  if (raw) return data;
46623
- return { count: data.value?.length ?? 0, items: (data.value ?? []).map(stripOData) };
46662
+ return plainCollection(data);
46624
46663
  }
46625
46664
  function registerDirectoryTools(server, client2) {
46626
46665
  server.registerTool(
@@ -46649,11 +46688,14 @@ function registerDirectoryTools(server, client2) {
46649
46688
  inputSchema: external_exports.object({
46650
46689
  view: viewParam(VIEWS2),
46651
46690
  limit: external_exports.number().int().min(1).max(200).optional().describe("Max contacts (default 50)"),
46652
- skip: external_exports.number().int().min(0).optional().describe("Offset for paging")
46691
+ skip: external_exports.number().int().min(0).optional().describe("Offset for paging"),
46692
+ nextLink: nextLinkParam
46653
46693
  })
46654
46694
  },
46655
- async ({ view, limit, skip }) => {
46656
- const data = await client2.get(
46695
+ async ({ view, limit, skip, nextLink }) => {
46696
+ const data = await fetchPage(
46697
+ client2,
46698
+ nextLink,
46657
46699
  `/me/contacts${qs3({
46658
46700
  $top: limit ?? 50,
46659
46701
  $skip: skip,
@@ -46672,11 +46714,14 @@ function registerDirectoryTools(server, client2) {
46672
46714
  annotations: { readOnlyHint: true },
46673
46715
  inputSchema: external_exports.object({
46674
46716
  view: viewParam(VIEWS2),
46675
- limit: external_exports.number().int().min(1).max(100).optional().describe("Max people (default 25)")
46717
+ limit: external_exports.number().int().min(1).max(100).optional().describe("Max people (default 25)"),
46718
+ nextLink: nextLinkParam
46676
46719
  })
46677
46720
  },
46678
- async ({ view, limit }) => {
46679
- const data = await client2.get(
46721
+ async ({ view, limit, nextLink }) => {
46722
+ const data = await fetchPage(
46723
+ client2,
46724
+ nextLink,
46680
46725
  `/me/people${qs3({
46681
46726
  $top: limit ?? 25,
46682
46727
  $select: "Id,DisplayName,ScoredEmailAddresses,JobTitle,CompanyName"
@@ -46692,11 +46737,14 @@ function registerDirectoryTools(server, client2) {
46692
46737
  annotations: { readOnlyHint: true },
46693
46738
  inputSchema: external_exports.object({
46694
46739
  view: viewParam(VIEWS2),
46695
- limit: external_exports.number().int().min(1).max(200).optional().describe("Max tasks (default 50)")
46740
+ limit: external_exports.number().int().min(1).max(200).optional().describe("Max tasks (default 50)"),
46741
+ nextLink: nextLinkParam
46696
46742
  })
46697
46743
  },
46698
- async ({ view, limit }) => {
46699
- const data = await client2.get(
46744
+ async ({ view, limit, nextLink }) => {
46745
+ const data = await fetchPage(
46746
+ client2,
46747
+ nextLink,
46700
46748
  `/me/tasks${qs3({
46701
46749
  $top: limit ?? 50,
46702
46750
  $select: "Id,Subject,Status,Importance,DueDateTime,CompletedDateTime"
package/dist/client.js CHANGED
@@ -198,17 +198,35 @@ export class OutlookClient {
198
198
  headers: prefer.length ? { Prefer: prefer.join(', ') } : undefined,
199
199
  });
200
200
  }
201
- /** GET an absolute URL — used only to follow `@odata.nextLink`. */
202
- async getAbsolute(url) {
201
+ /**
202
+ * GET an absolute URL — used only to follow `@odata.nextLink`.
203
+ *
204
+ * The link is absolute and already carries the base path
205
+ * (`https://outlook.office.com/api/v2.0/me/messages?$skip=25`), while the
206
+ * underlying client appends every path to that same base. So the base path is
207
+ * stripped here, and a link that is not under it — another origin, or another
208
+ * path on the same host — is refused rather than sent the bearer token.
209
+ */
210
+ async getAbsolute(url, opts = {}) {
203
211
  this.#requireConfigured();
204
212
  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.',
213
+ let target;
214
+ try {
215
+ target = new URL(url);
216
+ }
217
+ catch {
218
+ throw new McpToolError(`Refusing to follow a link that is not a URL: ${url}`, {
219
+ hint: 'Pass the nextLink value exactly as a previous listing returned it.',
220
+ });
221
+ }
222
+ const prefix = base.pathname.replace(/\/+$/, '');
223
+ if (target.origin !== base.origin ||
224
+ !(target.pathname === prefix || target.pathname.startsWith(`${prefix}/`))) {
225
+ throw new McpToolError(`Refusing to follow a link to ${target.origin}${target.pathname}.`, {
226
+ hint: `Pagination links must stay under the Outlook API base (${this.#baseUrl}).`,
209
227
  });
210
228
  }
211
- return this.#client().fetchJson('GET', target.pathname + target.search);
229
+ return this.get(target.pathname.slice(prefix.length) + target.search, opts);
212
230
  }
213
231
  /**
214
232
  * The single write path. Everything mutating goes through here so auth and
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { stripOData } from '../view.js';
3
+ /**
4
+ * Paging for every list tool.
5
+ *
6
+ * Outlook caps each page (`$top`) and signals the rest with
7
+ * `@odata.nextLink`. A listing that drops that link is indistinguishable from a
8
+ * complete one, so the model says with confidence that an item does not
9
+ * exist. Every list tool therefore returns `nextLink` when there is more, and
10
+ * accepts it back to fetch the next page.
11
+ */
12
+ export const nextLinkParam = z
13
+ .string()
14
+ .min(1)
15
+ .optional()
16
+ .describe('Continue a previous listing: pass its `nextLink` value unchanged to fetch the next page. When set, the paging/filter arguments are taken from the link and ignored here.');
17
+ /** Fetch the first page at `path`, or the page a previous `nextLink` points at. */
18
+ export function fetchPage(client, nextLink, path, opts = {}) {
19
+ return nextLink !== undefined ? client.getAbsolute(nextLink, opts) : client.get(path, opts);
20
+ }
21
+ /**
22
+ * Pass a collection through without a typed projection, trimming the OData
23
+ * envelope from each item but keeping the paging link.
24
+ */
25
+ export function plainCollection(data) {
26
+ const items = Array.isArray(data?.value) ? data.value : [];
27
+ const out = { count: items.length, items: items.map(stripOData) };
28
+ const next = data?.['@odata.nextLink'];
29
+ if (next)
30
+ out.nextLink = next;
31
+ return out;
32
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
3
- import { compactEvent, fullEvent, projectCollection, stripOData, } from '../view.js';
3
+ import { compactEvent, fullEvent, projectCollection, } from '../view.js';
4
+ import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
4
5
  import { VIEWS } from './mail.js';
5
6
  import { mailboxTimeZone } from '../timezone.js';
6
7
  const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview';
@@ -29,10 +30,11 @@ export function registerCalendarTools(server, client) {
29
30
  .optional()
30
31
  .describe('Windows time-zone name for the returned times, e.g. "Eastern Standard Time". NOT an IANA name like America/New_York.'),
31
32
  limit: z.number().int().min(1).max(200).optional().describe('Max events (default 50)'),
33
+ nextLink: nextLinkParam,
32
34
  }),
33
- }, async ({ view, start, end, timeZone, limit }) => {
35
+ }, async ({ view, start, end, timeZone, limit, nextLink }) => {
34
36
  const zone = timeZone ?? (await mailboxTimeZone(client));
35
- const data = await client.get(`/me/calendarview${qs({
37
+ const data = await fetchPage(client, nextLink, `/me/calendarview${qs({
36
38
  startDateTime: start,
37
39
  endDateTime: end,
38
40
  $select: EVENT_SELECT,
@@ -65,14 +67,12 @@ export function registerCalendarTools(server, client) {
65
67
  annotations: { readOnlyHint: true },
66
68
  inputSchema: z.object({
67
69
  view: viewParam(VIEWS),
70
+ nextLink: nextLinkParam,
68
71
  }),
69
- }, async ({ view }) => {
70
- const data = await client.get(`/me/calendars${qs({ $select: 'Id,Name,Color,CanEdit,Owner' })}`);
72
+ }, async ({ view, nextLink }) => {
73
+ const data = await fetchPage(client, nextLink, `/me/calendars${qs({ $select: 'Id,Name,Color,CanEdit,Owner' })}`);
71
74
  if (resolveView(view, VIEWS) === 'raw')
72
75
  return minifiedResult(data);
73
- return minifiedResult({
74
- count: data.value?.length ?? 0,
75
- items: (data.value ?? []).map(stripOData),
76
- });
76
+ return minifiedResult(plainCollection(data));
77
77
  });
78
78
  }
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
3
3
  import { stripOData } from '../view.js';
4
4
  import { VIEWS } from './mail.js';
5
+ import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
5
6
  function qs(params) {
6
7
  const parts = [];
7
8
  for (const [k, v] of Object.entries(params)) {
@@ -14,7 +15,7 @@ function qs(params) {
14
15
  function collection(data, raw) {
15
16
  if (raw)
16
17
  return data;
17
- return { count: data.value?.length ?? 0, items: (data.value ?? []).map(stripOData) };
18
+ return plainCollection(data);
18
19
  }
19
20
  export function registerDirectoryTools(server, client) {
20
21
  server.registerTool('outlook_get_profile', {
@@ -34,9 +35,10 @@ export function registerDirectoryTools(server, client) {
34
35
  view: viewParam(VIEWS),
35
36
  limit: z.number().int().min(1).max(200).optional().describe('Max contacts (default 50)'),
36
37
  skip: z.number().int().min(0).optional().describe('Offset for paging'),
38
+ nextLink: nextLinkParam,
37
39
  }),
38
- }, async ({ view, limit, skip }) => {
39
- const data = await client.get(`/me/contacts${qs({
40
+ }, async ({ view, limit, skip, nextLink }) => {
41
+ const data = await fetchPage(client, nextLink, `/me/contacts${qs({
40
42
  $top: limit ?? 50,
41
43
  $skip: skip,
42
44
  // `MobilePhone1`, not `MobilePhone`: the latter is the Graph name and
@@ -51,9 +53,10 @@ export function registerDirectoryTools(server, client) {
51
53
  inputSchema: z.object({
52
54
  view: viewParam(VIEWS),
53
55
  limit: z.number().int().min(1).max(100).optional().describe('Max people (default 25)'),
56
+ nextLink: nextLinkParam,
54
57
  }),
55
- }, async ({ view, limit }) => {
56
- const data = await client.get(`/me/people${qs({
58
+ }, async ({ view, limit, nextLink }) => {
59
+ const data = await fetchPage(client, nextLink, `/me/people${qs({
57
60
  $top: limit ?? 25,
58
61
  $select: 'Id,DisplayName,ScoredEmailAddresses,JobTitle,CompanyName',
59
62
  })}`);
@@ -65,9 +68,10 @@ export function registerDirectoryTools(server, client) {
65
68
  inputSchema: z.object({
66
69
  view: viewParam(VIEWS),
67
70
  limit: z.number().int().min(1).max(200).optional().describe('Max tasks (default 50)'),
71
+ nextLink: nextLinkParam,
68
72
  }),
69
- }, async ({ view, limit }) => {
70
- const data = await client.get(`/me/tasks${qs({
73
+ }, async ({ view, limit, nextLink }) => {
74
+ const data = await fetchPage(client, nextLink, `/me/tasks${qs({
71
75
  $top: limit ?? 50,
72
76
  $select: 'Id,Subject,Status,Importance,DueDateTime,CompletedDateTime',
73
77
  })}`);
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, viewParam, McpToolError } from '@chrischall/mcp-utils';
3
- import { compactFolder, compactMessage, fullMessage, projectCollection, stripOData, } from '../view.js';
3
+ import { compactFolder, compactMessage, fullMessage, projectCollection, } from '../view.js';
4
+ import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
4
5
  export const VIEWS = ['compact', 'full', 'raw'];
5
6
  /**
6
7
  * Well-known folder names the API accepts wherever a folder id is taken.
@@ -40,15 +41,16 @@ export function registerMailTools(server, client) {
40
41
  inputSchema: z.object({
41
42
  view: viewParam(VIEWS),
42
43
  limit: z.number().int().min(1).max(200).optional().describe('Max folders (default 50)'),
44
+ nextLink: nextLinkParam,
43
45
  }),
44
- }, async ({ view, limit }) => {
45
- const data = await client.get(`/me/mailfolders${qs({ $top: limit ?? 50 })}`);
46
+ }, async ({ view, limit, nextLink }) => {
47
+ const data = await fetchPage(client, nextLink, `/me/mailfolders${qs({ $top: limit ?? 50 })}`);
46
48
  if (resolveView(view, VIEWS) === 'raw')
47
49
  return minifiedResult(data);
48
50
  return minifiedResult(projectCollection(data, compactFolder, 'mail folder'));
49
51
  });
50
52
  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.",
53
+ 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 on a first-page request (the API rejects $search with $filter); pass `search` alone to search the whole mailbox. When following a `nextLink`, both are ignored, so echoing them back is harmless.",
52
54
  annotations: { readOnlyHint: true },
53
55
  inputSchema: z.object({
54
56
  view: viewParam(VIEWS),
@@ -62,10 +64,13 @@ export function registerMailTools(server, client) {
62
64
  search: z
63
65
  .string()
64
66
  .optional()
65
- .describe('Full-text search across the whole mailbox; cannot combine with unreadOnly'),
67
+ .describe('Full-text search across the whole mailbox; cannot combine with unreadOnly on a first page (ignored with nextLink)'),
68
+ nextLink: nextLinkParam,
66
69
  }),
67
- }, async ({ view, folder, limit, skip, unreadOnly, search }) => {
68
- if (search !== undefined && unreadOnly === true) {
70
+ }, async ({ view, folder, limit, skip, unreadOnly, search, nextLink }) => {
71
+ // With nextLink the filters come from the link and are ignored here, so
72
+ // the conflict only matters for a first-page request.
73
+ if (nextLink === undefined && search !== undefined && unreadOnly === true) {
69
74
  throw new McpToolError('`search` and `unreadOnly` cannot be combined.', {
70
75
  hint: 'Outlook rejects $search together with $filter. Search first, then filter the results.',
71
76
  });
@@ -86,7 +91,7 @@ export function registerMailTools(server, client) {
86
91
  if (unreadOnly === true)
87
92
  params.$filter = 'IsRead eq false';
88
93
  }
89
- const data = await client.get(`${base}${qs(params)}`);
94
+ const data = await fetchPage(client, nextLink, `${base}${qs(params)}`);
90
95
  const v = resolveView(view, VIEWS);
91
96
  if (v === 'raw')
92
97
  return minifiedResult(data);
@@ -117,14 +122,12 @@ export function registerMailTools(server, client) {
117
122
  annotations: { readOnlyHint: true },
118
123
  inputSchema: z.object({
119
124
  id: z.string().min(1).describe('Message Id'),
125
+ nextLink: nextLinkParam,
120
126
  }),
121
- }, async ({ id }) => {
122
- const data = await client.get(`/me/messages/${encodeURIComponent(id)}/attachments${qs({
127
+ }, async ({ id, nextLink }) => {
128
+ const data = await fetchPage(client, nextLink, `/me/messages/${encodeURIComponent(id)}/attachments${qs({
123
129
  $select: 'Id,Name,Size,ContentType',
124
130
  })}`);
125
- return minifiedResult({
126
- count: data.value?.length ?? 0,
127
- items: (data.value ?? []).map(stripOData),
128
- });
131
+ return minifiedResult(plainCollection(data));
129
132
  });
130
133
  }
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.1.5'; // x-release-please-version
13
+ export const VERSION = '0.2.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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>",
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.1.5",
9
+ "version": "0.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.1.5",
14
+ "version": "0.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },