@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({
|
package/dist/endpoints.json
CHANGED
|
@@ -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."
|
package/dist/graph-client.js
CHANGED
|
@@ -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]);
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
1768
|
-
combinedResponse["@odata.
|
|
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"
|
|
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.
|
|
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",
|
package/src/endpoints.json
CHANGED
|
@@ -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."
|