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

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."
@@ -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
  }
@@ -1767,7 +1774,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1767
1774
  if (combinedResponse["@odata.count"]) {
1768
1775
  combinedResponse["@odata.count"] = allItems.length;
1769
1776
  }
1770
- delete combinedResponse["@odata.nextLink"];
1777
+ if (unmergeablePage && nextLink) {
1778
+ combinedResponse["@odata.nextLink"] = nextLink;
1779
+ } else {
1780
+ delete combinedResponse["@odata.nextLink"];
1781
+ }
1771
1782
  response._meta = {
1772
1783
  ...response._meta,
1773
1784
  result_count: allItems.length,
@@ -1778,7 +1789,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager,
1778
1789
  combinedResponse["@odata.deltaLink"] = deltaLink;
1779
1790
  }
1780
1791
  logger.info(
1781
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
1792
+ `Pagination ${nextLink ? "incomplete" : "complete"}: collected ${allItems.length} items across ${pageCount} pages`
1782
1793
  );
1783
1794
  }
1784
1795
  }
@@ -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.";
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.1",
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."