@softeria/ms-365-mcp-server 0.128.2 → 0.129.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 CHANGED
@@ -297,7 +297,7 @@ Open WebUI supports MCP servers via HTTP transport with OAuth 2.1.
297
297
 
298
298
  3. Click **Register Client**.
299
299
 
300
- > **Note**: Dynamic client registration is enabled by default in HTTP mode. Use `--no-dynamic-registration` to disable it. If using a custom Azure Entra app, the platform type for your redirect URI depends on whether the app has a client secret: with a secret use "Web", without one use "Mobile and desktop applications" (never "Single-page application").
300
+ > **Note**: Dynamic client registration is enabled by default in HTTP mode. Use `--no-dynamic-registration` (or set `MS365_MCP_DISABLE_DCR=true`) to disable it. If using a custom Azure Entra app, the platform type for your redirect URI depends on whether the app has a client secret: with a secret use "Web", without one use "Mobile and desktop applications" (never "Single-page application").
301
301
 
302
302
  **Quick test setup** using the default Azure app (ID `ms-365` and `localhost:8080` are pre-configured):
303
303
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { z } from "zod";
3
3
  vi.mock("../logger.js", () => ({
4
4
  default: {
@@ -62,7 +62,7 @@ function makeConfig(overrides = {}) {
62
62
  ...overrides
63
63
  };
64
64
  }
65
- function createMockGraphClient(responses) {
65
+ function createMockGraphClient(responses, outputFormat = "json") {
66
66
  const responseQueue = [...responses || []];
67
67
  return {
68
68
  graphRequest: vi.fn().mockImplementation(async () => {
@@ -72,7 +72,12 @@ function createMockGraphClient(responses) {
72
72
  return {
73
73
  content: [{ type: "text", text: JSON.stringify({ value: [] }) }]
74
74
  };
75
- })
75
+ }),
76
+ // Fake serialize: prefix the JSON in toon mode so a test can tell the merged
77
+ // body went through serialize() and not a plain JSON.stringify.
78
+ serialize: vi.fn().mockImplementation(
79
+ (data) => outputFormat === "toon" ? `TOON:${JSON.stringify(data)}` : JSON.stringify(data)
80
+ )
76
81
  };
77
82
  }
78
83
  async function loadModule() {
@@ -161,6 +166,61 @@ describe("graph-tools", () => {
161
166
  expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
162
167
  expect(parsed["@odata.nextLink"]).toBeUndefined();
163
168
  });
169
+ it("merges all pages under --toon and encodes the combined result once (#560)", async () => {
170
+ const endpoint = makeEndpoint();
171
+ const config = makeConfig();
172
+ mockEndpoints.push(endpoint);
173
+ mockEndpointsJson = [config];
174
+ const graphClient = createMockGraphClient(
175
+ [
176
+ {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: JSON.stringify({
181
+ value: [{ id: "1" }, { id: "2" }],
182
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
183
+ })
184
+ }
185
+ ]
186
+ },
187
+ {
188
+ content: [{ type: "text", text: JSON.stringify({ value: [{ id: "3" }] }) }]
189
+ }
190
+ ],
191
+ "toon"
192
+ );
193
+ const server = createMockServer();
194
+ const { registerGraphTools } = await loadModule();
195
+ registerGraphTools(server, graphClient);
196
+ const tool = server.tools.get("test-tool");
197
+ const result = await tool.handler({ fetchAllPages: true });
198
+ for (const call of graphClient.graphRequest.mock.calls) {
199
+ expect(call[1]?.forceJsonOutput).toBe(true);
200
+ }
201
+ expect(graphClient.serialize).toHaveBeenCalledTimes(1);
202
+ expect(result.content[0].text.startsWith("TOON:")).toBe(true);
203
+ const parsed = JSON.parse(result.content[0].text.slice("TOON:".length));
204
+ expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
205
+ expect(parsed["@odata.nextLink"]).toBeUndefined();
206
+ });
207
+ it("does not inject value:[] when fetchAllPages hits a single-object (non-collection) GET", async () => {
208
+ const endpoint = makeEndpoint();
209
+ const config = makeConfig();
210
+ mockEndpoints.push(endpoint);
211
+ mockEndpointsJson = [config];
212
+ const graphClient = createMockGraphClient([
213
+ { content: [{ type: "text", text: JSON.stringify({ id: "abc", displayName: "Solo" }) }] }
214
+ ]);
215
+ const server = createMockServer();
216
+ const { registerGraphTools } = await loadModule();
217
+ registerGraphTools(server, graphClient);
218
+ const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
219
+ const parsed = JSON.parse(result.content[0].text);
220
+ expect(parsed).toEqual({ id: "abc", displayName: "Solo" });
221
+ expect(parsed.value).toBeUndefined();
222
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
223
+ });
164
224
  it("should stop at 100 page limit", async () => {
165
225
  const endpoint = makeEndpoint();
166
226
  const config = makeConfig();
@@ -530,7 +590,9 @@ describe("graph-tools", () => {
530
590
  registerGraphTools(server, graphClient);
531
591
  await server.tools.get("create-reply-draft").handler({
532
592
  messageId: "AAMk123",
533
- body: { Message: { body: { contentType: "html", content: "<p>hi</p>" } } }
593
+ body: { Message: { body: { contentType: "html", content: "<p>hi</p>" } } },
594
+ confirm: true
595
+ // destructive POST — required by isDestructiveOperation gate
534
596
  });
535
597
  const [, options] = graphClient.graphRequest.mock.calls[0];
536
598
  const prefer = options.headers["Prefer"];
@@ -571,7 +633,9 @@ describe("graph-tools", () => {
571
633
  await server.tools.get("upload-file-content").handler({
572
634
  driveId: "drive123",
573
635
  driveItemId: "item456",
574
- body: base64
636
+ body: base64,
637
+ confirm: true
638
+ // destructive PUT — required by isDestructiveOperation gate
575
639
  });
576
640
  const [path, options] = graphClient.graphRequest.mock.calls[0];
577
641
  expect(path).toBe("/drives/drive123/items/item456/content");
@@ -607,7 +671,9 @@ describe("graph-tools", () => {
607
671
  await server.tools.get("upload-file-content").handler({
608
672
  driveId: "d",
609
673
  driveItemId: "i",
610
- body: Buffer.from("%PDF-1.4").toString("base64")
674
+ body: Buffer.from("%PDF-1.4").toString("base64"),
675
+ confirm: true
676
+ // destructive PUT — required by isDestructiveOperation gate
611
677
  });
612
678
  const [, options] = graphClient.graphRequest.mock.calls[0];
613
679
  expect(options.headers["Content-Type"]).toBe("application/pdf");
@@ -706,6 +772,27 @@ describe("graph-tools", () => {
706
772
  expect(payload.size).toBe(12727);
707
773
  expect(payload.contentType).toBe("application/pdf");
708
774
  });
775
+ it("forces a JSON body on the metadata request so it works under --toon (#560)", async () => {
776
+ mockEndpoints.length = 0;
777
+ mockEndpointsJson = [];
778
+ const graphClient = {
779
+ graphRequest: vi.fn().mockResolvedValue({
780
+ content: [
781
+ {
782
+ type: "text",
783
+ text: JSON.stringify({ "@microsoft.graph.downloadUrl": "https://dl.example/x" })
784
+ }
785
+ ]
786
+ })
787
+ };
788
+ const server = createMockServer();
789
+ const { registerGraphTools } = await loadModule();
790
+ registerGraphTools(server, graphClient);
791
+ const result = await server.tools.get("get-download-url").handler({ target: "/drives/d1/items/item1/content" });
792
+ const [, opts] = graphClient.graphRequest.mock.calls[0];
793
+ expect(opts?.forceJsonOutput).toBe(true);
794
+ expect(JSON.parse(result.content[0].text).downloadUrl).toBe("https://dl.example/x");
795
+ });
709
796
  it("rejects query-shaped targets instead of silently changing request semantics", async () => {
710
797
  mockEndpoints.length = 0;
711
798
  mockEndpointsJson = [];
@@ -1267,4 +1354,247 @@ describe("graph-tools", () => {
1267
1354
  expect(server.tools.has("parse-teams-url")).toBe(true);
1268
1355
  });
1269
1356
  });
1357
+ describe("destructive operations require confirm: true", () => {
1358
+ const prevRequireConfirm = process.env.MS365_MCP_REQUIRE_CONFIRM;
1359
+ beforeEach(() => {
1360
+ process.env.MS365_MCP_REQUIRE_CONFIRM = "true";
1361
+ });
1362
+ afterEach(() => {
1363
+ if (prevRequireConfirm === void 0) delete process.env.MS365_MCP_REQUIRE_CONFIRM;
1364
+ else process.env.MS365_MCP_REQUIRE_CONFIRM = prevRequireConfirm;
1365
+ });
1366
+ it("rejects DELETE without confirm: true and does NOT call Graph", async () => {
1367
+ const endpoint = makeEndpoint({
1368
+ method: "delete",
1369
+ path: "/me/messages/:message-id",
1370
+ alias: "delete-mail-message"
1371
+ });
1372
+ const config = makeConfig({
1373
+ pathPattern: "/me/messages/{message-id}",
1374
+ method: "delete",
1375
+ toolName: "delete-mail-message"
1376
+ });
1377
+ mockEndpoints.push(endpoint);
1378
+ mockEndpointsJson = [config];
1379
+ const graphClient = createMockGraphClient();
1380
+ const server = createMockServer();
1381
+ const { registerGraphTools } = await loadModule();
1382
+ registerGraphTools(server, graphClient);
1383
+ const tool = server.tools.get("delete-mail-message");
1384
+ const result = await tool.handler({ messageId: "abc" });
1385
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1386
+ expect(result.isError).toBe(true);
1387
+ const payload = JSON.parse(result.content[0].text);
1388
+ expect(payload.error).toBe("confirmation_required");
1389
+ expect(payload.tool).toBe("delete-mail-message");
1390
+ expect(payload.destructive).toBe(true);
1391
+ });
1392
+ it("allows DELETE when confirm: true is passed", async () => {
1393
+ const endpoint = makeEndpoint({
1394
+ method: "delete",
1395
+ path: "/me/messages/:message-id",
1396
+ alias: "delete-mail-message"
1397
+ });
1398
+ const config = makeConfig({
1399
+ pathPattern: "/me/messages/{message-id}",
1400
+ method: "delete",
1401
+ toolName: "delete-mail-message"
1402
+ });
1403
+ mockEndpoints.push(endpoint);
1404
+ mockEndpointsJson = [config];
1405
+ const graphClient = createMockGraphClient([
1406
+ { content: [{ type: "text", text: JSON.stringify({ status: 204 }) }] }
1407
+ ]);
1408
+ const server = createMockServer();
1409
+ const { registerGraphTools } = await loadModule();
1410
+ registerGraphTools(server, graphClient);
1411
+ const tool = server.tools.get("delete-mail-message");
1412
+ await tool.handler({ messageId: "abc", confirm: true });
1413
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1414
+ });
1415
+ it("does NOT send `confirm` to Graph as a query/body parameter", async () => {
1416
+ const endpoint = makeEndpoint({
1417
+ method: "post",
1418
+ path: "/me/sendMail",
1419
+ alias: "send-mail",
1420
+ parameters: [{ name: "message", type: "Body", schema: z.any() }]
1421
+ });
1422
+ const config = makeConfig({
1423
+ pathPattern: "/me/sendMail",
1424
+ method: "post",
1425
+ toolName: "send-mail"
1426
+ });
1427
+ mockEndpoints.push(endpoint);
1428
+ mockEndpointsJson = [config];
1429
+ const graphClient = createMockGraphClient([
1430
+ { content: [{ type: "text", text: JSON.stringify({ status: 202 }) }] }
1431
+ ]);
1432
+ const server = createMockServer();
1433
+ const { registerGraphTools } = await loadModule();
1434
+ registerGraphTools(server, graphClient);
1435
+ const tool = server.tools.get("send-mail");
1436
+ await tool.handler({ message: { subject: "hi" }, confirm: true });
1437
+ const [url, opts] = graphClient.graphRequest.mock.calls[0];
1438
+ expect(url).not.toContain("confirm");
1439
+ const body = JSON.parse(opts.body);
1440
+ expect(body).not.toHaveProperty("confirm");
1441
+ });
1442
+ it("allows GET (read-only) regardless of confirm", async () => {
1443
+ const endpoint = makeEndpoint();
1444
+ const config = makeConfig();
1445
+ mockEndpoints.push(endpoint);
1446
+ mockEndpointsJson = [config];
1447
+ const graphClient = createMockGraphClient([
1448
+ { content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
1449
+ ]);
1450
+ const server = createMockServer();
1451
+ const { registerGraphTools } = await loadModule();
1452
+ registerGraphTools(server, graphClient);
1453
+ const tool = server.tools.get("test-tool");
1454
+ await tool.handler({});
1455
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1456
+ });
1457
+ it("allows POST endpoints flagged readOnly without confirm (e.g. find-meeting-times)", async () => {
1458
+ const endpoint = makeEndpoint({
1459
+ method: "post",
1460
+ path: "/me/findMeetingTimes",
1461
+ alias: "find-meeting-times"
1462
+ });
1463
+ const config = makeConfig({
1464
+ pathPattern: "/me/findMeetingTimes",
1465
+ method: "post",
1466
+ toolName: "find-meeting-times",
1467
+ readOnly: true
1468
+ });
1469
+ mockEndpoints.push(endpoint);
1470
+ mockEndpointsJson = [config];
1471
+ const graphClient = createMockGraphClient([
1472
+ { content: [{ type: "text", text: JSON.stringify({ meetingTimeSuggestions: [] }) }] }
1473
+ ]);
1474
+ const server = createMockServer();
1475
+ const { registerGraphTools } = await loadModule();
1476
+ registerGraphTools(server, graphClient);
1477
+ const tool = server.tools.get("find-meeting-times");
1478
+ await tool.handler({});
1479
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1480
+ });
1481
+ it("does NOT gate when MS365_MCP_REQUIRE_CONFIRM is unset (opt-in, off by default)", async () => {
1482
+ delete process.env.MS365_MCP_REQUIRE_CONFIRM;
1483
+ const endpoint = makeEndpoint({
1484
+ method: "delete",
1485
+ path: "/me/messages/:message-id",
1486
+ alias: "delete-mail-message"
1487
+ });
1488
+ const config = makeConfig({
1489
+ pathPattern: "/me/messages/{message-id}",
1490
+ method: "delete",
1491
+ toolName: "delete-mail-message"
1492
+ });
1493
+ mockEndpoints.push(endpoint);
1494
+ mockEndpointsJson = [config];
1495
+ const graphClient = createMockGraphClient([
1496
+ { content: [{ type: "text", text: JSON.stringify({ status: 204 }) }] }
1497
+ ]);
1498
+ const server = createMockServer();
1499
+ const { registerGraphTools } = await loadModule();
1500
+ registerGraphTools(server, graphClient);
1501
+ const tool = server.tools.get("delete-mail-message");
1502
+ await tool.handler({ messageId: "abc" });
1503
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1504
+ });
1505
+ it("does NOT gate when MS365_MCP_REQUIRE_CONFIRM=false (explicit off)", async () => {
1506
+ process.env.MS365_MCP_REQUIRE_CONFIRM = "false";
1507
+ const endpoint = makeEndpoint({
1508
+ method: "delete",
1509
+ path: "/me/messages/:message-id",
1510
+ alias: "delete-mail-message"
1511
+ });
1512
+ const config = makeConfig({
1513
+ pathPattern: "/me/messages/{message-id}",
1514
+ method: "delete",
1515
+ toolName: "delete-mail-message"
1516
+ });
1517
+ mockEndpoints.push(endpoint);
1518
+ mockEndpointsJson = [config];
1519
+ const graphClient = createMockGraphClient([
1520
+ { content: [{ type: "text", text: JSON.stringify({ status: 204 }) }] }
1521
+ ]);
1522
+ const server = createMockServer();
1523
+ const { registerGraphTools } = await loadModule();
1524
+ registerGraphTools(server, graphClient);
1525
+ const tool = server.tools.get("delete-mail-message");
1526
+ await tool.handler({ messageId: "abc" });
1527
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
1528
+ });
1529
+ it("rejects `confirm: false` as not equal to true", async () => {
1530
+ const endpoint = makeEndpoint({
1531
+ method: "patch",
1532
+ path: "/me/messages/:message-id",
1533
+ alias: "update-mail-message"
1534
+ });
1535
+ const config = makeConfig({
1536
+ pathPattern: "/me/messages/{message-id}",
1537
+ method: "patch",
1538
+ toolName: "update-mail-message"
1539
+ });
1540
+ mockEndpoints.push(endpoint);
1541
+ mockEndpointsJson = [config];
1542
+ const graphClient = createMockGraphClient();
1543
+ const server = createMockServer();
1544
+ const { registerGraphTools } = await loadModule();
1545
+ registerGraphTools(server, graphClient);
1546
+ const tool = server.tools.get("update-mail-message");
1547
+ const result = await tool.handler({ messageId: "abc", confirm: false });
1548
+ expect(graphClient.graphRequest).not.toHaveBeenCalled();
1549
+ expect(result.isError).toBe(true);
1550
+ });
1551
+ it("exposes confirm in the schema only for destructive tools", async () => {
1552
+ mockEndpoints.push(makeEndpoint());
1553
+ mockEndpointsJson = [makeConfig()];
1554
+ mockEndpoints.push(
1555
+ makeEndpoint({ method: "delete", alias: "destructive-tool", path: "/me/items/:item-id" })
1556
+ );
1557
+ mockEndpointsJson.push(
1558
+ makeConfig({
1559
+ method: "delete",
1560
+ toolName: "destructive-tool",
1561
+ pathPattern: "/me/items/{item-id}"
1562
+ })
1563
+ );
1564
+ const server = createMockServer();
1565
+ const { registerGraphTools } = await loadModule();
1566
+ registerGraphTools(server, createMockGraphClient());
1567
+ expect(server.tools.get("test-tool").schema).not.toHaveProperty("confirm");
1568
+ expect(server.tools.get("destructive-tool").schema).toHaveProperty("confirm");
1569
+ });
1570
+ });
1571
+ describe("isDestructiveOperation", () => {
1572
+ it("returns true for POST, PATCH, PUT, DELETE", async () => {
1573
+ const { isDestructiveOperation } = await loadModule();
1574
+ expect(isDestructiveOperation("POST", void 0)).toBe(true);
1575
+ expect(isDestructiveOperation("PATCH", void 0)).toBe(true);
1576
+ expect(isDestructiveOperation("PUT", void 0)).toBe(true);
1577
+ expect(isDestructiveOperation("DELETE", void 0)).toBe(true);
1578
+ });
1579
+ it("is case-insensitive", async () => {
1580
+ const { isDestructiveOperation } = await loadModule();
1581
+ expect(isDestructiveOperation("delete", void 0)).toBe(true);
1582
+ expect(isDestructiveOperation("Patch", void 0)).toBe(true);
1583
+ });
1584
+ it("returns false for GET / HEAD / OPTIONS", async () => {
1585
+ const { isDestructiveOperation } = await loadModule();
1586
+ expect(isDestructiveOperation("GET", void 0)).toBe(false);
1587
+ expect(isDestructiveOperation("HEAD", void 0)).toBe(false);
1588
+ expect(isDestructiveOperation("OPTIONS", void 0)).toBe(false);
1589
+ });
1590
+ it("returns false for POST endpoints flagged readOnly", async () => {
1591
+ const { isDestructiveOperation } = await loadModule();
1592
+ expect(isDestructiveOperation("POST", { readOnly: true })).toBe(false);
1593
+ });
1594
+ it("still returns true for PATCH/DELETE even if config.readOnly is set (should not happen but defensive)", async () => {
1595
+ const { isDestructiveOperation } = await loadModule();
1596
+ expect(isDestructiveOperation("PATCH", { readOnly: true })).toBe(true);
1597
+ expect(isDestructiveOperation("DELETE", { readOnly: true })).toBe(true);
1598
+ });
1599
+ });
1270
1600
  });
package/dist/cli.js CHANGED
@@ -40,7 +40,7 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
40
40
  "Enable OAuth Dynamic Client Registration endpoint (kept for backwards compatibility, now enabled by default in HTTP mode)"
41
41
  ).option(
42
42
  "--no-dynamic-registration",
43
- "Disable OAuth Dynamic Client Registration endpoint in HTTP mode"
43
+ "Disable OAuth Dynamic Client Registration endpoint in HTTP mode. Equivalent env var: MS365_MCP_DISABLE_DCR=true."
44
44
  ).option(
45
45
  "--auth-browser",
46
46
  "Use browser-based interactive OAuth flow instead of device code for stdio mode. Opens system browser with localhost callback for seamless sign-in."
@@ -158,7 +158,8 @@ function parseArgs() {
158
158
  options.toon = true;
159
159
  }
160
160
  if (options.http) {
161
- if (options.dynamicRegistration === false) {
161
+ const dcrDisabledByEnv = process.env.MS365_MCP_DISABLE_DCR === "true" || process.env.MS365_MCP_DISABLE_DCR === "1";
162
+ if (options.dynamicRegistration === false || dcrDisabledByEnv) {
162
163
  options.enableDynamicRegistration = false;
163
164
  } else {
164
165
  options.enableDynamicRegistration = true;
@@ -1766,6 +1766,7 @@
1766
1766
  "method": "post",
1767
1767
  "toolName": "search-query",
1768
1768
  "presets": ["personal", "search", "work"],
1769
+ "readOnly": true,
1769
1770
  "workScopes": [
1770
1771
  "Mail.Read",
1771
1772
  "Calendars.Read",
@@ -1976,6 +1977,7 @@
1976
1977
  "method": "post",
1977
1978
  "toolName": "get-presences-by-user-id",
1978
1979
  "presets": ["teams", "work"],
1980
+ "readOnly": true,
1979
1981
  "workScopes": ["Presence.Read.All"],
1980
1982
  "contentType": "application/json",
1981
1983
  "llmTip": "Gets presence for multiple users in a single call. Body: { ids: ['user-id-1', 'user-id-2', ...] }. Returns array of presence objects. More efficient than calling get-user-presence for each user. Maximum 650 user IDs per request."
@@ -2259,6 +2261,7 @@
2259
2261
  "method": "post",
2260
2262
  "toolName": "get-mail-tips",
2261
2263
  "presets": ["mail", "outlook", "personal"],
2264
+ "readOnly": true,
2262
2265
  "scopes": ["Mail.Read"],
2263
2266
  "contentType": "application/json",
2264
2267
  "llmTip": "Looks up MailTips for one or more recipients before sending an email — answers 'is this person on auto-reply / OOF?', 'will my email exceed their mailbox quota?', 'are they an external recipient?', 'is this a mailbox or distribution list?'. Body: { EmailAddresses: ['user@contoso.com', ...], MailTipsOptions: 'automaticReplies, mailboxFullStatus, customMailTip, externalMemberCount, totalMemberCount, maxMessageSize, deliveryRestriction, moderationStatus, recipientScope, recipientSuggestions' (comma-separated subset) }. Returns mailTips per recipient with the requested fields populated. Use this to short-circuit urgent emails when a recipient is OOF, or to warn before fanning out to a large DL."
@@ -2429,6 +2432,7 @@
2429
2432
  "method": "post",
2430
2433
  "toolName": "get-onenote-notebook-from-web-url",
2431
2434
  "presets": ["onenote", "personal"],
2435
+ "readOnly": true,
2432
2436
  "workScopes": ["Notes.Read"],
2433
2437
  "contentType": "application/json",
2434
2438
  "llmTip": "Resolves a OneNote notebook from its web URL (the link a user copies from OneNote / SharePoint / Teams). Body: { webUrl: 'https://...' }. Returns a notebook object with id, displayName, sectionsUrl, sectionGroupsUrl, and isShared — you can then list its sections via list-onenote-notebook-sections. Accepts user notebooks, group notebooks, and SharePoint-hosted team notebooks. Personal Microsoft accounts are not supported."
@@ -291,7 +291,7 @@ const microsoft_graph_chatMessage = z.lazy(
291
291
  deletedDateTime: z.string().regex(
292
292
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
293
293
  ).datetime({ offset: true }).describe(
294
- "Read only. Timestamp at which the chat message was deleted, or null if not deleted."
294
+ "Read-only. Timestamp at which the chat message was deleted, or null if not deleted."
295
295
  ).nullish(),
296
296
  etag: z.string().describe("Read-only. Version number of the chat message.").nullish(),
297
297
  eventDetail: microsoft_graph_eventMessageDetail.optional(),
@@ -300,12 +300,12 @@ const microsoft_graph_chatMessage = z.lazy(
300
300
  lastEditedDateTime: z.string().regex(
301
301
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
302
302
  ).datetime({ offset: true }).describe(
303
- "Read only. Timestamp when edits to the chat message were made. Triggers an 'Edited' flag in the Teams UI. If no edits are made the value is null."
303
+ "Read-only. Timestamp when edits to the chat message were made. Triggers an 'Edited' flag in the Teams UI. If no edits are made the value is null."
304
304
  ).nullish(),
305
305
  lastModifiedDateTime: z.string().regex(
306
306
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
307
307
  ).datetime({ offset: true }).describe(
308
- "Read only. Timestamp when the chat message is created (initial setting) or modified, including when a reaction is added or removed."
308
+ "Read-only. Timestamp when the chat message is created (initial setting) or modified, including when a reaction is added or removed."
309
309
  ).nullish(),
310
310
  locale: z.string().describe("Locale of the chat message set by the client. Always set to en-us.").optional(),
311
311
  mentions: z.array(microsoft_graph_chatMessageMention).describe(
@@ -373,7 +373,7 @@ const microsoft_graph_targetedChatMessage = z.object({
373
373
  lastModifiedDateTime: z.string().regex(
374
374
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
375
375
  ).datetime({ offset: true }).describe(
376
- "Read only. Timestamp when the chat message is created (initial setting) or modified, including when a reaction is added or removed."
376
+ "Read-only. Timestamp when the chat message is created (initial setting) or modified, including when a reaction is added or removed."
377
377
  ).nullish(),
378
378
  body: microsoft_graph_itemBody.optional(),
379
379
  subject: z.string().describe("The subject of the chat message, in plaintext.").nullish(),
@@ -385,14 +385,14 @@ const microsoft_graph_targetedChatMessage = z.object({
385
385
  deletedDateTime: z.string().regex(
386
386
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
387
387
  ).datetime({ offset: true }).describe(
388
- "Read only. Timestamp at which the chat message was deleted, or null if not deleted."
388
+ "Read-only. Timestamp at which the chat message was deleted, or null if not deleted."
389
389
  ).nullish(),
390
390
  etag: z.string().describe("Read-only. Version number of the chat message.").nullish(),
391
391
  eventDetail: microsoft_graph_eventMessageDetail.optional(),
392
392
  lastEditedDateTime: z.string().regex(
393
393
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
394
394
  ).datetime({ offset: true }).describe(
395
- "Read only. Timestamp when edits to the chat message were made. Triggers an 'Edited' flag in the Teams UI. If no edits are made the value is null."
395
+ "Read-only. Timestamp when edits to the chat message were made. Triggers an 'Edited' flag in the Teams UI. If no edits are made the value is null."
396
396
  ).nullish(),
397
397
  locale: z.string().describe("Locale of the chat message set by the client. Always set to en-us.").optional(),
398
398
  mentions: z.array(microsoft_graph_chatMessageMention).describe(
@@ -675,7 +675,7 @@ const microsoft_graph_user = z.object({
675
675
  "The state or province in the user's address. Maximum length is 128 characters. Requires $select to retrieve. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values)."
676
676
  ).nullish(),
677
677
  userPrincipalName: z.string().describe(
678
- "The user principal name (UPN) of the user. The UPN is an Internet-style sign-in name for the user based on the Internet standard RFC 822. By convention, this value should map to the user's email name. The general format is alias@domain, where the domain must be present in the tenant's collection of verified domains. This property is required when a user is created. The verified domains for the tenant can be accessed from the verifiedDomains property of organization.NOTE: This property can't contain accent characters. Only the following characters are allowed A - Z, a - z, 0 - 9, ' . - _ ! # ^ ~. For the complete list of allowed characters, see username policies. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, endsWith) and $orderby."
678
+ "The user principal name (UPN) of the user. The UPN is an Internet-style sign-in name for the user based on the Internet standard RFC 822. By convention, this value should map to the user's email name. The general format is alias@domain, where the domain must be present in the tenant's collection of verified domains. This property is required when a user is created. The verified domains for the tenant can be accessed from the verifiedDomains property of organization.NOTE: This property can't contain accent characters. Only the following characters are allowed A - Z, a - z, 0 - 9, ' . - _ ! # ^ ~. For the complete list of allowed characters, see username policies. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, endsWith) and $orderby. This property is subject to sensitive action restrictions; only specific privileged administrator roles can update it."
679
679
  ).nullish(),
680
680
  deletedDateTime: z.string().regex(
681
681
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
@@ -686,7 +686,7 @@ const microsoft_graph_user = z.object({
686
686
  "A freeform text entry field for the user to describe themselves. Requires $select to retrieve."
687
687
  ).nullish(),
688
688
  accountEnabled: z.boolean().describe(
689
- "true if the account is enabled; otherwise, false. This property is required when a user is created. Requires $select to retrieve. Supports $filter (eq, ne, not, and in)."
689
+ "true if the account is enabled; otherwise, false. This property is required when a user is created. Requires $select to retrieve. Supports $filter (eq, ne, not, and in). This property is subject to sensitive action restrictions; only specific privileged administrator roles can update it."
690
690
  ).nullish(),
691
691
  ageGroup: z.string().describe(
692
692
  "Sets the age group of the user. Allowed values: null, Minor, NotAdult, and Adult. For more information, see legal age group property definitions. Requires $select to retrieve. Supports $filter (eq, ne, not, and in)."
@@ -704,7 +704,7 @@ const microsoft_graph_user = z.object({
704
704
  "The birthday of the user. The Timestamp type represents date and time information using ISO 8601 format and is always in UTC. For example, midnight UTC on Jan 1, 2014, is 2014-01-01T00:00:00Z. Requires $select to retrieve."
705
705
  ).optional(),
706
706
  businessPhones: z.array(z.string()).describe(
707
- "The telephone numbers for the user. NOTE: Although it's a string collection, only one number can be set for this property. Read-only for users synced from the on-premises directory. Returned by default. Supports $filter (eq, not, ge, le, startsWith)."
707
+ "The telephone numbers for the user. NOTE: Although it's a string collection, only one number can be set for this property. Read-only for users synced from the on-premises directory. Returned by default. Supports $filter (eq, not, ge, le, startsWith). This property is subject to sensitive action restrictions; only specific privileged administrator roles can update it."
708
708
  ).optional(),
709
709
  city: z.string().describe(
710
710
  "The city where the user is located. Maximum length is 128 characters. Requires $select to retrieve. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values)."
@@ -1578,6 +1578,13 @@ const microsoft_graph_workbookRange = z.object({
1578
1578
  worksheet: microsoft_graph_workbookWorksheet.optional()
1579
1579
  }).passthrough();
1580
1580
  const create_excel_table_Body = z.object({ address: z.string().nullable(), hasHeaders: z.boolean().default(false) }).partial().passthrough();
1581
+ const microsoft_graph_groupAccessType = z.enum([
1582
+ "none",
1583
+ "private",
1584
+ "secret",
1585
+ "public",
1586
+ "unknownFutureValue"
1587
+ ]);
1581
1588
  const microsoft_graph_assignedLabel = z.object({
1582
1589
  displayName: z.string().describe("The display name of the label. Read-only.").nullish(),
1583
1590
  labelId: z.string().describe("The unique identifier of the label.").nullish()
@@ -1601,6 +1608,7 @@ const microsoft_graph_group = z.object({
1601
1608
  ).datetime({ offset: true }).describe(
1602
1609
  "Date and time when this object was deleted. Always null when the object hasn't been deleted."
1603
1610
  ).nullish(),
1611
+ accessType: microsoft_graph_groupAccessType.optional(),
1604
1612
  allowExternalSenders: z.boolean().describe(
1605
1613
  "Indicates if people external to the organization can send messages to the group. The default value is false. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
1606
1614
  ).nullish(),
@@ -1640,6 +1648,9 @@ const microsoft_graph_group = z.object({
1640
1648
  isAssignableToRole: z.boolean().describe(
1641
1649
  "Indicates whether this group can be assigned to a Microsoft Entra role. Optional. This property can only be set while creating the group and is immutable. If set to true, the securityEnabled property must also be set to true, visibility must be Hidden, and the group can't be a dynamic group (that is, groupTypes can't contain DynamicMembership). Only callers with at least the Privileged Role Administrator role can set this property. The caller must also be assigned the RoleManagement.ReadWrite.Directory permission to set this property or update the membership of such groups. For more, see Using a group to manage Microsoft Entra role assignmentsUsing this feature requires a Microsoft Entra ID P1 license. Returned by default. Supports $filter (eq, ne, not)."
1642
1650
  ).nullish(),
1651
+ isFavorite: z.boolean().describe(
1652
+ "Indicates whether the user marked the group as favorite. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
1653
+ ).nullish(),
1643
1654
  isManagementRestricted: z.boolean().describe(
1644
1655
  "Indicates whether the group is a member of a restricted management administrative unit. If not set, the default value is null and the default behavior is false. Read-only. To manage a group member of a restricted management administrative unit, the administrator or calling app must be assigned a Microsoft Entra role at the scope of the restricted management administrative unit. Requires $select to retrieve."
1645
1656
  ).nullish(),
@@ -1652,12 +1663,6 @@ const microsoft_graph_group = z.object({
1652
1663
  ).nullish(),
1653
1664
  mailEnabled: z.boolean().describe(
1654
1665
  "Specifies whether the group is mail-enabled. Required. Returned by default. Supports $filter (eq, ne, not)."
1655
- ).nullish(),
1656
- mailNickname: z.string().describe(
1657
- "The mail alias for the group, unique for Microsoft 365 groups in the organization. Maximum length is 64 characters. This property can contain only characters in the ASCII character set 0 - 127 except the following characters: @ () / [] ' ; : <> , SPACE. Required. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values)."
1658
- ).nullish(),
1659
- membershipRule: z.string().describe(
1660
- "The rule that determines members for this group if the group is a dynamic group (groupTypes contains DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith)."
1661
1666
  ).nullish()
1662
1667
  }).passthrough().passthrough();
1663
1668
  const microsoft_graph_groupCollectionResponse = z.object({
@@ -2895,7 +2900,7 @@ const microsoft_graph_channel = z.lazy(
2895
2900
  id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
2896
2901
  createdDateTime: z.string().regex(
2897
2902
  /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
2898
- ).datetime({ offset: true }).describe("Read only. Timestamp at which the channel was created.").nullish(),
2903
+ ).datetime({ offset: true }).describe("Read-only. Timestamp at which the channel was created.").nullish(),
2899
2904
  description: z.string().describe("Optional textual description for the channel.").nullish(),
2900
2905
  displayName: z.string().describe(
2901
2906
  "Channel name as it will appear to the user in Microsoft Teams. The maximum length is 50 characters."
@@ -3007,7 +3012,7 @@ const microsoft_graph_team = z.lazy(
3007
3012
  ).nullish(),
3008
3013
  allChannels: z.array(microsoft_graph_channel).describe("List of channels either hosted in or shared with the team (incoming channels).").optional(),
3009
3014
  channels: z.array(microsoft_graph_channel).describe("The collection of channels and messages associated with the team.").optional(),
3010
- group: microsoft_graph_group.describe("[Note: Simplified from 75 properties to 25 most common ones]").optional(),
3015
+ group: microsoft_graph_group.describe("[Note: Simplified from 80 properties to 25 most common ones]").optional(),
3011
3016
  incomingChannels: z.array(microsoft_graph_channel).describe("List of channels shared with the team.").optional(),
3012
3017
  installedApps: z.array(microsoft_graph_teamsAppInstallation).describe("The apps installed in this team.").optional(),
3013
3018
  members: z.array(microsoft_graph_conversationMember).describe("Members and owners of the team.").optional(),
@@ -4973,6 +4978,7 @@ const schemas = {
4973
4978
  microsoft_graph_workbookRangeFormat,
4974
4979
  microsoft_graph_workbookRange,
4975
4980
  create_excel_table_Body,
4981
+ microsoft_graph_groupAccessType,
4976
4982
  microsoft_graph_assignedLabel,
4977
4983
  microsoft_graph_licenseProcessingState,
4978
4984
  microsoft_graph_group,
@@ -6860,6 +6866,7 @@ You can search within a folder hierarchy, a whole drive, or files shared with th
6860
6866
  ).datetime({ offset: true }).describe(
6861
6867
  "Date and time when this object was deleted. Always null when the object hasn't been deleted."
6862
6868
  ).nullish(),
6869
+ accessType: microsoft_graph_groupAccessType.optional(),
6863
6870
  allowExternalSenders: z.boolean().describe(
6864
6871
  "Indicates if people external to the organization can send messages to the group. The default value is false. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
6865
6872
  ).nullish(),
@@ -6899,6 +6906,9 @@ You can search within a folder hierarchy, a whole drive, or files shared with th
6899
6906
  isAssignableToRole: z.boolean().describe(
6900
6907
  "Indicates whether this group can be assigned to a Microsoft Entra role. Optional. This property can only be set while creating the group and is immutable. If set to true, the securityEnabled property must also be set to true, visibility must be Hidden, and the group can't be a dynamic group (that is, groupTypes can't contain DynamicMembership). Only callers with at least the Privileged Role Administrator role can set this property. The caller must also be assigned the RoleManagement.ReadWrite.Directory permission to set this property or update the membership of such groups. For more, see Using a group to manage Microsoft Entra role assignmentsUsing this feature requires a Microsoft Entra ID P1 license. Returned by default. Supports $filter (eq, ne, not)."
6901
6908
  ).nullish(),
6909
+ isFavorite: z.boolean().describe(
6910
+ "Indicates whether the user marked the group as favorite. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
6911
+ ).nullish(),
6902
6912
  isManagementRestricted: z.boolean().describe(
6903
6913
  "Indicates whether the group is a member of a restricted management administrative unit. If not set, the default value is null and the default behavior is false. Read-only. To manage a group member of a restricted management administrative unit, the administrator or calling app must be assigned a Microsoft Entra role at the scope of the restricted management administrative unit. Requires $select to retrieve."
6904
6914
  ).nullish(),
@@ -6911,12 +6921,6 @@ You can search within a folder hierarchy, a whole drive, or files shared with th
6911
6921
  ).nullish(),
6912
6922
  mailEnabled: z.boolean().describe(
6913
6923
  "Specifies whether the group is mail-enabled. Required. Returned by default. Supports $filter (eq, ne, not)."
6914
- ).nullish(),
6915
- mailNickname: z.string().describe(
6916
- "The mail alias for the group, unique for Microsoft 365 groups in the organization. Maximum length is 64 characters. This property can contain only characters in the ASCII character set 0 - 127 except the following characters: @ () / [] ' ; : <> , SPACE. Required. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values)."
6917
- ).nullish(),
6918
- membershipRule: z.string().describe(
6919
- "The rule that determines members for this group if the group is a dynamic group (groupTypes contains DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith)."
6920
6924
  ).nullish()
6921
6925
  }).passthrough().passthrough()
6922
6926
  }
@@ -6973,6 +6977,7 @@ You can create or update the following types of group: By default, this operatio
6973
6977
  ).datetime({ offset: true }).describe(
6974
6978
  "Date and time when this object was deleted. Always null when the object hasn't been deleted."
6975
6979
  ).nullish(),
6980
+ accessType: microsoft_graph_groupAccessType.optional(),
6976
6981
  allowExternalSenders: z.boolean().describe(
6977
6982
  "Indicates if people external to the organization can send messages to the group. The default value is false. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
6978
6983
  ).nullish(),
@@ -7012,6 +7017,9 @@ You can create or update the following types of group: By default, this operatio
7012
7017
  isAssignableToRole: z.boolean().describe(
7013
7018
  "Indicates whether this group can be assigned to a Microsoft Entra role. Optional. This property can only be set while creating the group and is immutable. If set to true, the securityEnabled property must also be set to true, visibility must be Hidden, and the group can't be a dynamic group (that is, groupTypes can't contain DynamicMembership). Only callers with at least the Privileged Role Administrator role can set this property. The caller must also be assigned the RoleManagement.ReadWrite.Directory permission to set this property or update the membership of such groups. For more, see Using a group to manage Microsoft Entra role assignmentsUsing this feature requires a Microsoft Entra ID P1 license. Returned by default. Supports $filter (eq, ne, not)."
7014
7019
  ).nullish(),
7020
+ isFavorite: z.boolean().describe(
7021
+ "Indicates whether the user marked the group as favorite. Requires $select to retrieve. Supported only on the Get group API (GET /groups/{ID})."
7022
+ ).nullish(),
7015
7023
  isManagementRestricted: z.boolean().describe(
7016
7024
  "Indicates whether the group is a member of a restricted management administrative unit. If not set, the default value is null and the default behavior is false. Read-only. To manage a group member of a restricted management administrative unit, the administrator or calling app must be assigned a Microsoft Entra role at the scope of the restricted management administrative unit. Requires $select to retrieve."
7017
7025
  ).nullish(),
@@ -7024,12 +7032,6 @@ You can create or update the following types of group: By default, this operatio
7024
7032
  ).nullish(),
7025
7033
  mailEnabled: z.boolean().describe(
7026
7034
  "Specifies whether the group is mail-enabled. Required. Returned by default. Supports $filter (eq, ne, not)."
7027
- ).nullish(),
7028
- mailNickname: z.string().describe(
7029
- "The mail alias for the group, unique for Microsoft 365 groups in the organization. Maximum length is 64 characters. This property can contain only characters in the ASCII character set 0 - 127 except the following characters: @ () / [] ' ; : <> , SPACE. Required. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values)."
7030
- ).nullish(),
7031
- membershipRule: z.string().describe(
7032
- "The rule that determines members for this group if the group is a dynamic group (groupTypes contains DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith)."
7033
7035
  ).nullish()
7034
7036
  }).passthrough().passthrough()
7035
7037
  }
@@ -9320,7 +9322,7 @@ be returned for more than one recipient at one time. The requested MailTips are
9320
9322
  method: "get",
9321
9323
  path: "/me/inferenceClassification/overrides",
9322
9324
  alias: "list-focused-inbox-overrides",
9323
- description: `Get the overrides that a user has set up to always classify messages from certain senders in specific ways. Each override corresponds to an SMTP address of a sender. Initially, a user does not have any overrides.`,
9325
+ description: `Get the overrides that a user has set up to always classify messages from certain senders in specific ways. Each override corresponds to an SMTP address of a sender. Initially, a user doesn't have any overrides.`,
9324
9326
  requestFormat: "json",
9325
9327
  parameters: [
9326
9328
  {
@@ -9555,7 +9557,7 @@ the new SMTP address is the only way to 'update' the override for this sender.`,
9555
9557
  method: "get",
9556
9558
  path: "/me/mailFolders",
9557
9559
  alias: "list-mail-folders",
9558
- description: `Get the mail folder collection directly under the root folder of the signed-in user. The returned collection includes any mail search folders directly under the root. By default, this operation does not return hidden folders. Use a query parameter includeHiddenFolders to include them in the response. This operation does not return all mail folders in a mailbox, only the child folders of the root folder. To return all mail folders in a mailbox, each child folder must be traversed separately.`,
9560
+ description: `Get the mail folder collection directly under the root folder of the signed-in user. The returned collection includes any mail search folders directly under the root. By default, this operation doesn't return hidden folders. Use a query parameter includeHiddenFolders to include them in the response. This operation doesn't return all mail folders in a mailbox, only the child folders of the root folder. To return all mail folders in a mailbox, each child folder must be traversed separately.`,
9559
9561
  requestFormat: "json",
9560
9562
  parameters: [
9561
9563
  {
@@ -9658,7 +9660,7 @@ the new SMTP address is the only way to 'update' the override for this sender.`,
9658
9660
  path: "/me/mailFolders/:mailFolderId/childFolders",
9659
9661
  alias: "list-mail-child-folders",
9660
9662
  description: `Get the folder collection under the specified folder. You can use the .../me/mailFolders shortcut to get the top-level
9661
- folder collection and navigate to another folder. By default, this operation does not return hidden folders. Use a query parameter includeHiddenFolders to include them in the response.`,
9663
+ folder collection and navigate to another folder. By default, this operation doesn't return hidden folders. Use a query parameter includeHiddenFolders to include them in the response.`,
9662
9664
  requestFormat: "json",
9663
9665
  parameters: [
9664
9666
  {
@@ -133,11 +133,25 @@ class GraphClient {
133
133
  }
134
134
  return JSON.stringify(data, null, pretty ? 2 : void 0);
135
135
  }
136
+ /**
137
+ * Encode a value in the configured format (json/toon). The fetchAllPages merge
138
+ * uses this to encode the combined result once, after parsing pages as JSON (#560).
139
+ * Compact by default like JSON.stringify, so JSON-mode output is byte-identical.
140
+ */
141
+ serialize(data, pretty = false) {
142
+ return this.serializeData(data, this.outputFormat, pretty);
143
+ }
136
144
  async graphRequest(endpoint, options = {}) {
137
145
  try {
138
146
  logger.info(`Calling ${endpoint} with options: ${JSON.stringify(options)}`);
139
147
  const result = await this.makeRequest(endpoint, options);
140
- return this.formatJsonResponse(result, options.rawResponse, options.excludeResponse);
148
+ const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
149
+ return this.formatJsonResponse(
150
+ result,
151
+ options.rawResponse,
152
+ options.excludeResponse,
153
+ outputFormat
154
+ );
141
155
  } catch (error) {
142
156
  logger.error(`Error in Graph API request: ${error}`);
143
157
  return {
@@ -146,10 +160,10 @@ class GraphClient {
146
160
  };
147
161
  }
148
162
  }
149
- formatJsonResponse(data, rawResponse = false, excludeResponse = false) {
163
+ formatJsonResponse(data, rawResponse = false, excludeResponse = false, outputFormat = this.outputFormat) {
150
164
  if (excludeResponse) {
151
165
  return {
152
- content: [{ type: "text", text: this.serializeData({ success: true }, this.outputFormat) }]
166
+ content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }]
153
167
  };
154
168
  }
155
169
  if (data && typeof data === "object" && "_headers" in data) {
@@ -163,17 +177,13 @@ class GraphClient {
163
177
  }
164
178
  if (rawResponse) {
165
179
  return {
166
- content: [
167
- { type: "text", text: this.serializeData(responseData.data, this.outputFormat) }
168
- ],
180
+ content: [{ type: "text", text: this.serializeData(responseData.data, outputFormat) }],
169
181
  _meta: meta
170
182
  };
171
183
  }
172
184
  if (responseData.data === null || responseData.data === void 0) {
173
185
  return {
174
- content: [
175
- { type: "text", text: this.serializeData({ success: true }, this.outputFormat) }
176
- ],
186
+ content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }],
177
187
  _meta: meta
178
188
  };
179
189
  }
@@ -191,19 +201,19 @@ class GraphClient {
191
201
  removeODataProps2(responseData.data);
192
202
  return {
193
203
  content: [
194
- { type: "text", text: this.serializeData(responseData.data, this.outputFormat, true) }
204
+ { type: "text", text: this.serializeData(responseData.data, outputFormat, true) }
195
205
  ],
196
206
  _meta: meta
197
207
  };
198
208
  }
199
209
  if (rawResponse) {
200
210
  return {
201
- content: [{ type: "text", text: this.serializeData(data, this.outputFormat) }]
211
+ content: [{ type: "text", text: this.serializeData(data, outputFormat) }]
202
212
  };
203
213
  }
204
214
  if (data === null || data === void 0) {
205
215
  return {
206
- content: [{ type: "text", text: this.serializeData({ success: true }, this.outputFormat) }]
216
+ content: [{ type: "text", text: this.serializeData({ success: true }, outputFormat) }]
207
217
  };
208
218
  }
209
219
  const removeODataProps = (obj) => {
@@ -219,7 +229,7 @@ class GraphClient {
219
229
  };
220
230
  removeODataProps(data);
221
231
  return {
222
- content: [{ type: "text", text: this.serializeData(data, this.outputFormat, true) }]
232
+ content: [{ type: "text", text: this.serializeData(data, outputFormat, true) }]
223
233
  };
224
234
  }
225
235
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import logger from "./logger.js";
3
3
  import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
4
+ import { isDestructiveOperation } from "./lib/destructive-ops.js";
4
5
  import {
5
6
  getEndpointScopeGroups,
6
7
  getMissingAllowedScopesForGroups,
@@ -67,6 +68,9 @@ function paginationAllowed() {
67
68
  if (raw === void 0 || raw === "") return true;
68
69
  return !/^(0|false|no)$/i.test(raw.trim());
69
70
  }
71
+ function isConfirmGateEnabled() {
72
+ return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
73
+ }
70
74
  function formatDisabledToolsForLog(disabledTools) {
71
75
  const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
72
76
  const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
@@ -324,7 +328,10 @@ const UTILITY_TOOLS = [
324
328
  accountAccessToken = await authManager.getTokenForAccount(accountParam);
325
329
  }
326
330
  const response = await graphClient.graphRequest(itemPath, {
327
- accessToken: accountAccessToken
331
+ accessToken: accountAccessToken,
332
+ // We JSON.parse the metadata below, so force JSON - under --toon it'd be
333
+ // TOON and the parse would fail, masking a real item as "no download url".
334
+ forceJsonOutput: true
328
335
  });
329
336
  if (response?.isError) {
330
337
  return response;
@@ -390,6 +397,26 @@ function registerUtilityToolWithMcp(server, utility, ctx) {
390
397
  }
391
398
  async function executeGraphTool(tool, config, graphClient, params, authManager) {
392
399
  logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
400
+ if (isConfirmGateEnabled() && isDestructiveOperation(tool.method, config) && params.confirm !== true) {
401
+ logger.warn(
402
+ `Refusing destructive tool ${tool.alias} (${tool.method.toUpperCase()}): missing confirm: true`
403
+ );
404
+ return {
405
+ content: [
406
+ {
407
+ type: "text",
408
+ text: JSON.stringify({
409
+ error: "confirmation_required",
410
+ tool: tool.alias,
411
+ method: tool.method.toUpperCase(),
412
+ destructive: true,
413
+ message: 'This tool modifies user data. Re-call with parameter "confirm": true after the user has explicitly approved the operation.'
414
+ })
415
+ }
416
+ ],
417
+ isError: true
418
+ };
419
+ }
393
420
  const requestId = randomUUID();
394
421
  const startTime = Date.now();
395
422
  const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
@@ -427,6 +454,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
427
454
  for (const [paramName, paramValue] of Object.entries(params)) {
428
455
  if ([
429
456
  "account",
457
+ "confirm",
430
458
  "fetchAllPages",
431
459
  "includeHeaders",
432
460
  "excludeResponse",
@@ -587,7 +615,6 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
587
615
  logger.info(
588
616
  `Making graph request to ${path2} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
589
617
  );
590
- let response = await graphClient.graphRequest(path2, options);
591
618
  const fetchAllPages = params.fetchAllPages === true;
592
619
  const paginationEnabled = paginationAllowed();
593
620
  if (fetchAllPages && !paginationEnabled) {
@@ -595,58 +622,69 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
595
622
  "fetchAllPages requested but MS365_MCP_ALLOW_PAGINATION is disabled; returning first page only"
596
623
  );
597
624
  }
598
- if (fetchAllPages && paginationEnabled && response?.content?.[0]?.text) {
625
+ const mergePages = fetchAllPages && paginationEnabled;
626
+ if (mergePages) {
627
+ options.forceJsonOutput = true;
628
+ }
629
+ let response = await graphClient.graphRequest(path2, options);
630
+ if (mergePages && response?.content?.[0]?.text) {
631
+ let combinedResponse;
599
632
  try {
600
- let combinedResponse = JSON.parse(response.content[0].text);
601
- let allItems = combinedResponse.value || [];
602
- let nextLink = combinedResponse["@odata.nextLink"];
603
- let pageCount = 1;
604
- const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
605
- const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
606
- let deltaLink = combinedResponse["@odata.deltaLink"];
607
- while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
608
- logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
609
- const url = new URL(nextLink);
610
- const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
611
- const nextOptions = { ...options };
612
- const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
613
- if (nextResponse?.content?.[0]?.text) {
614
- const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
615
- if (nextJsonResponse.value && Array.isArray(nextJsonResponse.value)) {
616
- allItems = allItems.concat(nextJsonResponse.value);
617
- }
618
- nextLink = nextJsonResponse["@odata.nextLink"];
619
- if (nextJsonResponse["@odata.deltaLink"]) {
620
- deltaLink = nextJsonResponse["@odata.deltaLink"];
633
+ combinedResponse = JSON.parse(response.content[0].text);
634
+ const firstValue = combinedResponse.value;
635
+ if (Array.isArray(firstValue)) {
636
+ let allItems = firstValue;
637
+ let nextLink = combinedResponse["@odata.nextLink"];
638
+ let pageCount = 1;
639
+ const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
640
+ const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
641
+ let deltaLink = combinedResponse["@odata.deltaLink"];
642
+ while (nextLink && pageCount < maxPages && allItems.length < maxItems) {
643
+ logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
644
+ const url = new URL(nextLink);
645
+ const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
646
+ const nextOptions = { ...options };
647
+ const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
648
+ if (nextResponse?.content?.[0]?.text) {
649
+ const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
650
+ if (Array.isArray(nextJsonResponse.value)) {
651
+ allItems = allItems.concat(nextJsonResponse.value);
652
+ }
653
+ nextLink = nextJsonResponse["@odata.nextLink"];
654
+ if (nextJsonResponse["@odata.deltaLink"]) {
655
+ deltaLink = nextJsonResponse["@odata.deltaLink"];
656
+ }
657
+ pageCount++;
658
+ } else {
659
+ break;
621
660
  }
622
- pageCount++;
623
- } else {
624
- break;
625
661
  }
626
- }
627
- if (pageCount >= maxPages) {
628
- logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
629
- }
630
- if (allItems.length >= maxItems) {
631
- logger.warn(
632
- `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
662
+ if (pageCount >= maxPages) {
663
+ logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
664
+ }
665
+ if (allItems.length >= maxItems) {
666
+ logger.warn(
667
+ `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
668
+ );
669
+ }
670
+ combinedResponse.value = allItems;
671
+ if (combinedResponse["@odata.count"]) {
672
+ combinedResponse["@odata.count"] = allItems.length;
673
+ }
674
+ delete combinedResponse["@odata.nextLink"];
675
+ if (deltaLink) {
676
+ combinedResponse["@odata.deltaLink"] = deltaLink;
677
+ }
678
+ logger.info(
679
+ `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
633
680
  );
634
681
  }
635
- combinedResponse.value = allItems;
636
- if (combinedResponse["@odata.count"]) {
637
- combinedResponse["@odata.count"] = allItems.length;
638
- }
639
- delete combinedResponse["@odata.nextLink"];
640
- if (deltaLink) {
641
- combinedResponse["@odata.deltaLink"] = deltaLink;
642
- }
643
- response.content[0].text = JSON.stringify(combinedResponse);
644
- logger.info(
645
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
646
- );
647
682
  } catch (e) {
648
683
  logger.error(`Error during pagination: ${e}`);
649
684
  }
685
+ if (combinedResponse !== void 0) {
686
+ response.content[0].text = graphClient.serialize(combinedResponse);
687
+ }
650
688
  }
651
689
  if (response?.content?.[0]?.text) {
652
690
  const responseText = response.content[0].text;
@@ -815,6 +853,12 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
815
853
  }
816
854
  paramSchema["includeHeaders"] = z.boolean().describe("Include response headers (including ETag) in the response metadata").optional();
817
855
  paramSchema["excludeResponse"] = z.boolean().describe("Exclude the full response body and only return success or failure indication").optional();
856
+ const destructive = isDestructiveOperation(tool.method, endpointConfig);
857
+ if (destructive) {
858
+ paramSchema["confirm"] = z.boolean().describe(
859
+ '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.'
860
+ ).optional();
861
+ }
818
862
  if (endpointConfig?.supportsTimezone) {
819
863
  paramSchema["timezone"] = z.string().describe(
820
864
  'IANA timezone name (e.g., "America/New_York", "Europe/London", "Asia/Tokyo") for calendar event times. If not specified, times are returned in UTC.'
@@ -843,7 +887,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
843
887
  {
844
888
  title: tool.alias,
845
889
  readOnlyHint: isReadOnlyTool,
846
- destructiveHint: !isReadOnlyTool && ["POST", "PATCH", "DELETE"].includes(tool.method.toUpperCase()),
890
+ destructiveHint: destructive,
847
891
  openWorldHint: true
848
892
  // All tools call Microsoft Graph API
849
893
  },
@@ -1117,11 +1161,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1117
1161
  async ({ tool_name }) => {
1118
1162
  const entry = toolsRegistry.get(tool_name);
1119
1163
  if (entry) {
1120
- const schema = describeToolSchema(
1121
- entry.tool,
1122
- entry.config?.llmTip,
1123
- entry.config?.descriptionOverride
1124
- );
1164
+ const schema = describeToolSchema(entry.tool, entry.config);
1125
1165
  return {
1126
1166
  content: [{ type: "text", text: JSON.stringify(schema, null, 2) }]
1127
1167
  };
@@ -1196,6 +1236,7 @@ export {
1196
1236
  UTILITY_TOOLS,
1197
1237
  buildDiscoverySearchIndex,
1198
1238
  buildToolsRegistry,
1239
+ isDestructiveOperation,
1199
1240
  registerDiscoveryTools,
1200
1241
  registerGraphTools,
1201
1242
  scoreDiscoveryQuery
@@ -0,0 +1,9 @@
1
+ function isDestructiveOperation(method, config) {
2
+ const upper = method.toUpperCase();
3
+ if (!["POST", "PATCH", "PUT", "DELETE"].includes(upper)) return false;
4
+ if (upper === "POST" && config?.readOnly) return false;
5
+ return true;
6
+ }
7
+ export {
8
+ isDestructiveOperation
9
+ };
@@ -1,4 +1,5 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
+ import { isDestructiveOperation } from "./destructive-ops.js";
2
3
  function unwrapOptional(schema) {
3
4
  const def = schema._def;
4
5
  const typeName = def?.typeName;
@@ -7,7 +8,7 @@ function unwrapOptional(schema) {
7
8
  }
8
9
  return { inner: schema, optional: false };
9
10
  }
10
- function describeToolSchema(tool, llmTip, descriptionOverride) {
11
+ function describeToolSchema(tool, config) {
11
12
  const params = (tool.parameters ?? []).map((p) => {
12
13
  const { inner, optional } = unwrapOptional(p.schema);
13
14
  const isPath = p.type === "Path";
@@ -21,11 +22,21 @@ function describeToolSchema(tool, llmTip, descriptionOverride) {
21
22
  schema
22
23
  };
23
24
  });
25
+ if (isDestructiveOperation(tool.method, config)) {
26
+ params.push({
27
+ name: "confirm",
28
+ in: "Query",
29
+ required: false,
30
+ 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.',
31
+ schema: { type: "boolean" }
32
+ });
33
+ }
34
+ const llmTip = config?.llmTip;
24
35
  return {
25
36
  name: tool.alias,
26
37
  method: tool.method.toUpperCase(),
27
38
  path: tool.path,
28
- description: descriptionOverride ?? tool.description ?? "",
39
+ description: config?.descriptionOverride ?? tool.description ?? "",
29
40
  ...llmTip ? { llmTip } : {},
30
41
  parameters: params
31
42
  };
@@ -212,8 +212,10 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
212
212
  - **Read-only mode**: use `--read-only` to disable all write operations (send, delete, update, create)
213
213
  - **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
214
214
  - **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
215
+ - **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
215
216
  - **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stdout (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, error_type?, error_code? }`. The schema is intentionally narrow — tool parameters and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
216
217
  - **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
218
+ - **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
217
219
 
218
220
  ## Exposed Endpoints
219
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.128.2",
3
+ "version": "0.129.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",
@@ -1766,6 +1766,7 @@
1766
1766
  "method": "post",
1767
1767
  "toolName": "search-query",
1768
1768
  "presets": ["personal", "search", "work"],
1769
+ "readOnly": true,
1769
1770
  "workScopes": [
1770
1771
  "Mail.Read",
1771
1772
  "Calendars.Read",
@@ -1976,6 +1977,7 @@
1976
1977
  "method": "post",
1977
1978
  "toolName": "get-presences-by-user-id",
1978
1979
  "presets": ["teams", "work"],
1980
+ "readOnly": true,
1979
1981
  "workScopes": ["Presence.Read.All"],
1980
1982
  "contentType": "application/json",
1981
1983
  "llmTip": "Gets presence for multiple users in a single call. Body: { ids: ['user-id-1', 'user-id-2', ...] }. Returns array of presence objects. More efficient than calling get-user-presence for each user. Maximum 650 user IDs per request."
@@ -2259,6 +2261,7 @@
2259
2261
  "method": "post",
2260
2262
  "toolName": "get-mail-tips",
2261
2263
  "presets": ["mail", "outlook", "personal"],
2264
+ "readOnly": true,
2262
2265
  "scopes": ["Mail.Read"],
2263
2266
  "contentType": "application/json",
2264
2267
  "llmTip": "Looks up MailTips for one or more recipients before sending an email — answers 'is this person on auto-reply / OOF?', 'will my email exceed their mailbox quota?', 'are they an external recipient?', 'is this a mailbox or distribution list?'. Body: { EmailAddresses: ['user@contoso.com', ...], MailTipsOptions: 'automaticReplies, mailboxFullStatus, customMailTip, externalMemberCount, totalMemberCount, maxMessageSize, deliveryRestriction, moderationStatus, recipientScope, recipientSuggestions' (comma-separated subset) }. Returns mailTips per recipient with the requested fields populated. Use this to short-circuit urgent emails when a recipient is OOF, or to warn before fanning out to a large DL."
@@ -2429,6 +2432,7 @@
2429
2432
  "method": "post",
2430
2433
  "toolName": "get-onenote-notebook-from-web-url",
2431
2434
  "presets": ["onenote", "personal"],
2435
+ "readOnly": true,
2432
2436
  "workScopes": ["Notes.Read"],
2433
2437
  "contentType": "application/json",
2434
2438
  "llmTip": "Resolves a OneNote notebook from its web URL (the link a user copies from OneNote / SharePoint / Teams). Body: { webUrl: 'https://...' }. Returns a notebook object with id, displayName, sectionsUrl, sectionGroupsUrl, and isShared — you can then list its sections via list-onenote-notebook-sections. Accepts user notebooks, group notebooks, and SharePoint-hosted team notebooks. Personal Microsoft accounts are not supported."