@softeria/ms-365-mcp-server 0.161.0 → 0.161.2

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.
@@ -458,9 +458,10 @@ describe("graph-tools", () => {
458
458
  const server = createMockServer();
459
459
  const { registerGraphTools } = await loadModule();
460
460
  registerGraphTools(server, graphClient);
461
- await server.tools.get("test-tool").handler({ fetchAllPages: true });
461
+ const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
462
462
  const [payload] = auditLogMock.mock.calls[0];
463
463
  expect(payload.result_count).toBe(2);
464
+ expect(JSON.parse(result.content[0].text)["@odata.nextLink"]).toBeUndefined();
464
465
  expect(payload.result_has_more).toBe(true);
465
466
  expect(payload.response_bytes).toBe(100);
466
467
  } finally {
@@ -471,6 +472,64 @@ describe("graph-tools", () => {
471
472
  }
472
473
  }
473
474
  });
475
+ it("reports result_has_more when a page is not a collection", async () => {
476
+ mockEndpoints.push(makeEndpoint());
477
+ mockEndpointsJson = [makeConfig()];
478
+ const pageThreeLink = "https://graph.microsoft.com/v1.0/me/messages?$skip=4";
479
+ const graphClient = createMockGraphClient([
480
+ {
481
+ content: [
482
+ {
483
+ type: "text",
484
+ text: JSON.stringify({
485
+ value: [{ id: "1" }, { id: "2" }],
486
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
487
+ })
488
+ }
489
+ ],
490
+ _meta: { http_status: 200, result_count: 2, result_has_more: true, response_bytes: 1e3 }
491
+ },
492
+ {
493
+ content: [
494
+ {
495
+ type: "text",
496
+ text: JSON.stringify({
497
+ value: [{ id: "3" }, { id: "4" }],
498
+ "@odata.nextLink": pageThreeLink
499
+ })
500
+ }
501
+ ],
502
+ _meta: { http_status: 200, result_count: 2, result_has_more: true, response_bytes: 900 }
503
+ },
504
+ {
505
+ content: [
506
+ {
507
+ type: "text",
508
+ text: JSON.stringify({
509
+ message: "OK!",
510
+ contentType: "application/json",
511
+ encoding: "base64",
512
+ contentLength: 9,
513
+ contentBytes: "eyJ2YWx1"
514
+ })
515
+ }
516
+ ],
517
+ _meta: { http_status: 200, response_bytes: 700 }
518
+ }
519
+ ]);
520
+ const server = createMockServer();
521
+ const { registerGraphTools } = await loadModule();
522
+ registerGraphTools(server, graphClient);
523
+ const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
524
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(3);
525
+ const body = JSON.parse(result.content[0].text);
526
+ expect(body.value.map((item) => item.id)).toEqual(["1", "2", "3", "4"]);
527
+ expect(body["@odata.nextLink"]).toBe(pageThreeLink);
528
+ const [payload] = auditLogMock.mock.calls[0];
529
+ expect(payload.result_count).toBe(4);
530
+ expect(payload.result_has_more).toBe(true);
531
+ expect(payload.response_bytes).toBe(1900);
532
+ });
474
533
  it("omits the volume fields when the client supplied none", async () => {
475
534
  const endpoint = makeEndpoint({ method: "get", path: "/me", alias: "get-current-user" });
476
535
  const config = makeConfig({
@@ -2324,6 +2324,7 @@
2324
2324
  "pathPattern": "/groups/{group-id}/threads/{conversationThread-id}/reply",
2325
2325
  "method": "post",
2326
2326
  "toolName": "reply-to-group-thread",
2327
+ "descriptionOverride": "Reply to a conversation thread in a Microsoft 365 group, adding a new post to the thread. Use list-group-threads to obtain the thread ID.",
2327
2328
  "presets": ["work"],
2328
2329
  "workScopes": ["Group.ReadWrite.All"],
2329
2330
  "llmTip": "Legacy — Microsoft recommends Teams channels instead of group threads."
@@ -401,7 +401,7 @@ class GraphClient {
401
401
  const removeODataProps2 = (obj) => {
402
402
  if (typeof obj === "object" && obj !== null) {
403
403
  Object.keys(obj).forEach((key) => {
404
- if (key.startsWith("@odata.") && key !== "@odata.nextLink" && key !== "@odata.deltaLink") {
404
+ if (key.startsWith("@odata.") && key !== "@odata.nextLink" && key !== "@odata.deltaLink" && key !== "@odata.count") {
405
405
  delete obj[key];
406
406
  } else if (typeof obj[key] === "object") {
407
407
  removeODataProps2(obj[key]);
@@ -430,7 +430,7 @@ class GraphClient {
430
430
  const removeODataProps = (obj) => {
431
431
  if (typeof obj === "object" && obj !== null) {
432
432
  Object.keys(obj).forEach((key) => {
433
- if (key.startsWith("@odata.") && key !== "@odata.nextLink" && key !== "@odata.deltaLink") {
433
+ if (key.startsWith("@odata.") && key !== "@odata.nextLink" && key !== "@odata.deltaLink" && key !== "@odata.count") {
434
434
  delete obj[key];
435
435
  } else if (typeof obj[key] === "object") {
436
436
  removeODataProps(obj[key]);
@@ -1726,6 +1726,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1726
1726
  const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
1727
1727
  const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
1728
1728
  let deltaLink = combinedResponse["@odata.deltaLink"];
1729
+ let unmergeablePage = false;
1729
1730
  while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
1730
1731
  logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
1731
1732
  const url = new URL(nextLink);
@@ -1739,9 +1740,14 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1739
1740
  }
1740
1741
  if (nextResponse?.content?.[0]?.text) {
1741
1742
  const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
1742
- if (Array.isArray(nextJsonResponse.value)) {
1743
- allItems = allItems.concat(nextJsonResponse.value);
1743
+ if (!Array.isArray(nextJsonResponse.value)) {
1744
+ logger.warn(
1745
+ `Pagination stopped at page ${pageCount + 1}: response was not a collection`
1746
+ );
1747
+ unmergeablePage = true;
1748
+ break;
1744
1749
  }
1750
+ allItems = allItems.concat(nextJsonResponse.value);
1745
1751
  nextLink = nextJsonResponse["@odata.nextLink"];
1746
1752
  if (typeof totalResponseBytes === "number" && typeof nextResponse._meta?.response_bytes === "number") {
1747
1753
  totalResponseBytes += nextResponse._meta.response_bytes;
@@ -1751,6 +1757,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1751
1757
  }
1752
1758
  pageCount++;
1753
1759
  } else {
1760
+ unmergeablePage = true;
1754
1761
  break;
1755
1762
  }
1756
1763
  }
@@ -1764,10 +1771,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1764
1771
  );
1765
1772
  }
1766
1773
  combinedResponse.value = allItems;
1767
- if (combinedResponse["@odata.count"]) {
1768
- combinedResponse["@odata.count"] = allItems.length;
1774
+ if (unmergeablePage && nextLink) {
1775
+ combinedResponse["@odata.nextLink"] = nextLink;
1776
+ } else {
1777
+ delete combinedResponse["@odata.nextLink"];
1769
1778
  }
1770
- delete combinedResponse["@odata.nextLink"];
1771
1779
  response._meta = {
1772
1780
  ...response._meta,
1773
1781
  result_count: allItems.length,
@@ -1778,7 +1786,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1778
1786
  combinedResponse["@odata.deltaLink"] = deltaLink;
1779
1787
  }
1780
1788
  logger.info(
1781
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
1789
+ `Pagination ${nextLink ? "incomplete" : "complete"}: collected ${allItems.length} items across ${pageCount} pages`
1782
1790
  );
1783
1791
  }
1784
1792
  }
@@ -31,7 +31,7 @@ function isFetchAllPagesApplicable(tool) {
31
31
  const FILTER_PARAM_DESCRIPTION = "OData filter expression. Add $count=true for advanced filters (flag/flagStatus, contains()). Cannot combine with $search.";
32
32
  const SEARCH_PARAM_DESCRIPTION = "KQL search query in one pair of double quotes; directory (users/groups) instead quotes each clause with no outer pair. Cannot combine with $filter.";
33
33
  const SELECT_PARAM_DESCRIPTION = "Comma-separated fields to return, e.g. id,subject,from,receivedDateTime";
34
- const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachments on a message or event. Only navigation properties can be expanded: expanding a non-navigation property such as a message body fails with "Parsing OData Select and Expand failed", and an unsupported value may be ignored rather than reported. Request ordinary fields with $select instead.';
34
+ const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachments on a message or event. Only navigation properties can be expanded: expanding a non-navigation property such as a message body fails with "Parsing OData Select and Expand failed". Request ordinary fields with $select instead. Some APIs accept only one relationship per request, so prefer a single value. Some relationships support nested selection, e.g. children($select=id,name).';
35
35
  const ORDERBY_PARAM_DESCRIPTION = "Sort expression, e.g. receivedDateTime desc";
36
36
  const TOP_PARAM_DESCRIPTION = "Page size (Graph $top). Start small (e.g. 5\u201315) so responses fit the model context; raise only if needed. Use $select to return fewer fields per item. For more rows, use @odata.nextLink from the response instead of a very large $top.";
37
37
  const SKIP_PARAM_DESCRIPTION = "Items to skip for pagination. Not supported with $search.";
@@ -44,7 +44,7 @@ function isSkiptokenApplicable(tool, paramNames) {
44
44
  }
45
45
  return false;
46
46
  }
47
- const COUNT_PARAM_DESCRIPTION = "Set true to enable advanced query mode (ConsistencyLevel: eventual). Required for complex $filter on flag/flagStatus or contains().";
47
+ const COUNT_PARAM_DESCRIPTION = "Set true to enable advanced query mode (ConsistencyLevel: eventual). Required for complex $filter on flag/flagStatus or contains(). The response then includes @odata.count, the total for the filtered collection, which can exceed the items returned when fetchAllPages hits its page/item cap.";
48
48
  const CONFIRM_PARAM_DESCRIPTION = 'For destructive operations when the confirm gate is enabled (MS365_MCP_REQUIRE_CONFIRM=true; off by default). Set to true only after the user has explicitly approved this action. When the gate is on, calls without confirm: true return { error: "confirmation_required" } without touching user data.';
49
49
  const TIMEZONE_PARAM_DESCRIPTION = 'IANA timezone name (e.g., "America/New_York", "Europe/London", "Asia/Tokyo") for calendar event times. If not specified, times are returned in UTC.';
50
50
  const EXPAND_EXTENDED_PROPERTIES_PARAM_DESCRIPTION = "When true, expands singleValueExtendedProperties on each event. Use this to retrieve custom extended properties (e.g., sync metadata) stored on calendar events.";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.161.0",
4
+ "version": "0.161.2",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -2324,6 +2324,7 @@
2324
2324
  "pathPattern": "/groups/{group-id}/threads/{conversationThread-id}/reply",
2325
2325
  "method": "post",
2326
2326
  "toolName": "reply-to-group-thread",
2327
+ "descriptionOverride": "Reply to a conversation thread in a Microsoft 365 group, adding a new post to the thread. Use list-group-threads to obtain the thread ID.",
2327
2328
  "presets": ["work"],
2328
2329
  "workScopes": ["Group.ReadWrite.All"],
2329
2330
  "llmTip": "Legacy — Microsoft recommends Teams channels instead of group threads."