@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 +1 -1
- package/dist/__tests__/graph-tools.test.js +336 -6
- package/dist/cli.js +3 -2
- package/dist/endpoints.json +4 -0
- package/dist/generated/client.js +34 -32
- package/dist/graph-client.js +23 -13
- package/dist/graph-tools.js +93 -52
- package/dist/lib/destructive-ops.js +9 -0
- package/dist/lib/tool-schema.js +13 -2
- package/docs/deployment.md +2 -0
- package/package.json +1 -1
- package/src/endpoints.json +4 -0
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
|
-
|
|
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;
|
package/dist/endpoints.json
CHANGED
|
@@ -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."
|
package/dist/generated/client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
{
|
package/dist/graph-client.js
CHANGED
|
@@ -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
|
-
|
|
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 },
|
|
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,
|
|
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,
|
|
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 },
|
|
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,
|
|
232
|
+
content: [{ type: "text", text: this.serializeData(data, outputFormat, true) }]
|
|
223
233
|
};
|
|
224
234
|
}
|
|
225
235
|
}
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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:
|
|
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
|
+
};
|
package/dist/lib/tool-schema.js
CHANGED
|
@@ -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,
|
|
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
|
};
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/endpoints.json
CHANGED
|
@@ -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."
|