@softeria/ms-365-mcp-server 0.115.0 → 0.116.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.
- package/README.md +3 -0
- package/dist/__tests__/graph-tools.test.js +91 -0
- package/dist/graph-tools.js +29 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -573,6 +573,9 @@ Environment variables:
|
|
|
573
573
|
- `MS365_MCP_FORCE_WORK_SCOPES=true|1`: Backwards compatibility for MS365_MCP_ORG_MODE
|
|
574
574
|
- `MS365_MCP_OUTPUT_FORMAT=toon`: Enable TOON output format (alternative to --toon flag)
|
|
575
575
|
- `MS365_MCP_MAX_TOP=<n>`: Hard cap for Graph `$top` / `top` on list requests (positive integer). When the model passes a larger value, the server clamps it to `n` so responses stay smaller. Example: `MS365_MCP_MAX_TOP=15`
|
|
576
|
+
- `MS365_MCP_MAX_PAGES=<n>`: Maximum number of pages followed when a tool is called with `fetchAllPages: true` (positive integer, default `100`). Bounds memory and latency for large result sets.
|
|
577
|
+
- `MS365_MCP_MAX_ITEMS=<n>`: Maximum number of items accumulated when `fetchAllPages: true` (positive integer, default `10000`). Pagination stops and the response is truncated once this many items are collected.
|
|
578
|
+
- `MS365_MCP_ALLOW_PAGINATION=0|false|no`: Disable multi-page following entirely. When set, the `fetchAllPages` parameter is not advertised on tools, and any request that still passes it returns only the first page (default: pagination enabled).
|
|
576
579
|
- `MS365_MCP_BODY_FORMAT=html`: Return email bodies as HTML instead of plain text (default: text)
|
|
577
580
|
- `MS365_MCP_CLOUD_TYPE=global|china`: Microsoft cloud environment (alternative to --cloud flag)
|
|
578
581
|
- `LOG_LEVEL`: Set logging level (default: 'info')
|
|
@@ -183,6 +183,97 @@ describe("graph-tools", () => {
|
|
|
183
183
|
await tool.handler({ fetchAllPages: true });
|
|
184
184
|
expect(graphClient.graphRequest).toHaveBeenCalledTimes(100);
|
|
185
185
|
});
|
|
186
|
+
describe("pagination env caps", () => {
|
|
187
|
+
const prev = {
|
|
188
|
+
pages: process.env.MS365_MCP_MAX_PAGES,
|
|
189
|
+
items: process.env.MS365_MCP_MAX_ITEMS,
|
|
190
|
+
allow: process.env.MS365_MCP_ALLOW_PAGINATION
|
|
191
|
+
};
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
const restore = (name, value) => value === void 0 ? delete process.env[name] : process.env[name] = value;
|
|
194
|
+
restore("MS365_MCP_MAX_PAGES", prev.pages);
|
|
195
|
+
restore("MS365_MCP_MAX_ITEMS", prev.items);
|
|
196
|
+
restore("MS365_MCP_ALLOW_PAGINATION", prev.allow);
|
|
197
|
+
});
|
|
198
|
+
const paginatingResponses = (count) => Array.from({ length: count }, (_, i) => ({
|
|
199
|
+
content: [
|
|
200
|
+
{
|
|
201
|
+
type: "text",
|
|
202
|
+
text: JSON.stringify({
|
|
203
|
+
value: [{ id: `item-${i}` }],
|
|
204
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=" + (i + 1)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
}));
|
|
209
|
+
it("should honor MS365_MCP_MAX_PAGES below the default", async () => {
|
|
210
|
+
process.env.MS365_MCP_MAX_PAGES = "2";
|
|
211
|
+
mockEndpoints.push(makeEndpoint());
|
|
212
|
+
mockEndpointsJson = [makeConfig()];
|
|
213
|
+
const graphClient = createMockGraphClient(paginatingResponses(5));
|
|
214
|
+
const server = createMockServer();
|
|
215
|
+
const { registerGraphTools } = await loadModule();
|
|
216
|
+
registerGraphTools(server, graphClient);
|
|
217
|
+
await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
218
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(2);
|
|
219
|
+
});
|
|
220
|
+
it("should honor MS365_MCP_MAX_ITEMS below the default", async () => {
|
|
221
|
+
process.env.MS365_MCP_MAX_ITEMS = "2";
|
|
222
|
+
mockEndpoints.push(makeEndpoint());
|
|
223
|
+
mockEndpointsJson = [makeConfig()];
|
|
224
|
+
const graphClient = createMockGraphClient([
|
|
225
|
+
{
|
|
226
|
+
content: [
|
|
227
|
+
{
|
|
228
|
+
type: "text",
|
|
229
|
+
text: JSON.stringify({
|
|
230
|
+
value: [{ id: "1" }, { id: "2" }],
|
|
231
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
},
|
|
236
|
+
...paginatingResponses(3)
|
|
237
|
+
]);
|
|
238
|
+
const server = createMockServer();
|
|
239
|
+
const { registerGraphTools } = await loadModule();
|
|
240
|
+
registerGraphTools(server, graphClient);
|
|
241
|
+
const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
242
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(JSON.parse(result.content[0].text).value).toHaveLength(2);
|
|
244
|
+
});
|
|
245
|
+
it("should not follow nextLink when MS365_MCP_ALLOW_PAGINATION is disabled", async () => {
|
|
246
|
+
process.env.MS365_MCP_ALLOW_PAGINATION = "0";
|
|
247
|
+
mockEndpoints.push(makeEndpoint());
|
|
248
|
+
mockEndpointsJson = [makeConfig()];
|
|
249
|
+
const graphClient = createMockGraphClient(paginatingResponses(5));
|
|
250
|
+
const server = createMockServer();
|
|
251
|
+
const { registerGraphTools } = await loadModule();
|
|
252
|
+
registerGraphTools(server, graphClient);
|
|
253
|
+
await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
254
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
|
|
255
|
+
expect(server.tools.get("test-tool").schema.fetchAllPages).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
it("should advertise fetchAllPages when pagination is enabled", async () => {
|
|
258
|
+
delete process.env.MS365_MCP_ALLOW_PAGINATION;
|
|
259
|
+
mockEndpoints.push(makeEndpoint());
|
|
260
|
+
mockEndpointsJson = [makeConfig()];
|
|
261
|
+
const server = createMockServer();
|
|
262
|
+
const { registerGraphTools } = await loadModule();
|
|
263
|
+
registerGraphTools(server, createMockGraphClient());
|
|
264
|
+
expect(server.tools.get("test-tool").schema.fetchAllPages).toBeDefined();
|
|
265
|
+
});
|
|
266
|
+
it("should reflect MS365_MCP_MAX_PAGES in the fetchAllPages description", async () => {
|
|
267
|
+
process.env.MS365_MCP_MAX_PAGES = "7";
|
|
268
|
+
mockEndpoints.push(makeEndpoint());
|
|
269
|
+
mockEndpointsJson = [makeConfig()];
|
|
270
|
+
const server = createMockServer();
|
|
271
|
+
const { registerGraphTools } = await loadModule();
|
|
272
|
+
registerGraphTools(server, createMockGraphClient());
|
|
273
|
+
const schema = server.tools.get("test-tool").schema.fetchAllPages;
|
|
274
|
+
expect(schema.description).toContain("up to 7 pages");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
186
277
|
});
|
|
187
278
|
describe("parameter describe() overrides", () => {
|
|
188
279
|
it("should apply custom descriptions to OData parameters", async () => {
|
package/dist/graph-tools.js
CHANGED
|
@@ -41,6 +41,23 @@ function clampTopQueryParam(queryParams) {
|
|
|
41
41
|
logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
|
|
42
42
|
queryParams["$top"] = String(cap);
|
|
43
43
|
}
|
|
44
|
+
const DEFAULT_MAX_PAGES = 100;
|
|
45
|
+
const DEFAULT_MAX_ITEMS = 1e4;
|
|
46
|
+
function positiveIntFromEnv(name, defaultValue) {
|
|
47
|
+
const raw = process.env[name];
|
|
48
|
+
if (raw === void 0 || raw === "") return defaultValue;
|
|
49
|
+
const n = Number.parseInt(raw, 10);
|
|
50
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
51
|
+
logger.warn(`Ignoring invalid ${name}=${JSON.stringify(raw)} (use a positive integer)`);
|
|
52
|
+
return defaultValue;
|
|
53
|
+
}
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
function paginationAllowed() {
|
|
57
|
+
const raw = process.env.MS365_MCP_ALLOW_PAGINATION;
|
|
58
|
+
if (raw === void 0 || raw === "") return true;
|
|
59
|
+
return !/^(0|false|no)$/i.test(raw.trim());
|
|
60
|
+
}
|
|
44
61
|
function formatDisabledToolsForLog(disabledTools) {
|
|
45
62
|
const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
|
|
46
63
|
const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
|
|
@@ -359,14 +376,20 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
359
376
|
);
|
|
360
377
|
let response = await graphClient.graphRequest(path2, options);
|
|
361
378
|
const fetchAllPages = params.fetchAllPages === true;
|
|
362
|
-
|
|
379
|
+
const paginationEnabled = paginationAllowed();
|
|
380
|
+
if (fetchAllPages && !paginationEnabled) {
|
|
381
|
+
logger.info(
|
|
382
|
+
"fetchAllPages requested but MS365_MCP_ALLOW_PAGINATION is disabled; returning first page only"
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (fetchAllPages && paginationEnabled && response?.content?.[0]?.text) {
|
|
363
386
|
try {
|
|
364
387
|
let combinedResponse = JSON.parse(response.content[0].text);
|
|
365
388
|
let allItems = combinedResponse.value || [];
|
|
366
389
|
let nextLink = combinedResponse["@odata.nextLink"];
|
|
367
390
|
let pageCount = 1;
|
|
368
|
-
const maxPages =
|
|
369
|
-
const maxItems =
|
|
391
|
+
const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
|
|
392
|
+
const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
|
|
370
393
|
while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
|
|
371
394
|
logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
|
|
372
395
|
const url = new URL(nextLink);
|
|
@@ -519,9 +542,10 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
|
|
|
519
542
|
paramSchema[pathParamName] = z.string().describe(`Path parameter: ${pathParamName}`);
|
|
520
543
|
}
|
|
521
544
|
}
|
|
522
|
-
if (tool.method.toUpperCase() === "GET" && tool.path.includes("/")) {
|
|
545
|
+
if (tool.method.toUpperCase() === "GET" && tool.path.includes("/") && paginationAllowed()) {
|
|
546
|
+
const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
|
|
523
547
|
paramSchema["fetchAllPages"] = z.boolean().describe(
|
|
524
|
-
|
|
548
|
+
`Follow @odata.nextLink and merge up to ${maxPages} pages into one response. Can return enormous payloads\u2014only when the user explicitly needs a full export. Prefer a small $top first, then paginate or narrow with $filter/$search.`
|
|
525
549
|
).optional();
|
|
526
550
|
}
|
|
527
551
|
if (paramSchema["filter"] !== void 0 || paramSchema["$filter"] !== void 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softeria/ms-365-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.116.1",
|
|
4
4
|
"description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|