@softeria/ms-365-mcp-server 0.157.2 → 0.158.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 +74 -0
- package/dist/__tests__/graph-tools.test.js +407 -120
- package/dist/cli.js +13 -1
- package/dist/generated/client-beta.js +1 -0
- package/dist/generated/client.js +10 -1
- package/dist/graph-tools.js +250 -55
- package/dist/lib/attachment-tickets.js +11 -1
- package/dist/lib/select-projection.js +9 -8
- package/dist/lib/user-field-policy.js +69 -0
- package/dist/server.js +32 -43
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { z } from "zod";
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
2
|
import { tmpdir } from "os";
|
|
4
3
|
import { join } from "path";
|
|
5
|
-
import {
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { z } from "zod";
|
|
6
6
|
const loggerMock = vi.hoisted(() => ({
|
|
7
7
|
info: vi.fn(),
|
|
8
8
|
warn: vi.fn(),
|
|
@@ -2331,16 +2331,11 @@ describe("graph-tools", () => {
|
|
|
2331
2331
|
};
|
|
2332
2332
|
const server = createMockServer();
|
|
2333
2333
|
const { registerGraphTools } = await loadModule();
|
|
2334
|
-
registerGraphTools(
|
|
2335
|
-
server,
|
|
2336
|
-
graphClient,
|
|
2337
|
-
false,
|
|
2338
|
-
void 0,
|
|
2339
|
-
false,
|
|
2334
|
+
registerGraphTools(server, graphClient, {
|
|
2340
2335
|
authManager,
|
|
2341
|
-
true,
|
|
2342
|
-
["user1@domain.com", "user2@domain.com"]
|
|
2343
|
-
);
|
|
2336
|
+
multiAccount: true,
|
|
2337
|
+
accountNames: ["user1@domain.com", "user2@domain.com"]
|
|
2338
|
+
});
|
|
2344
2339
|
const outputPath = join(tmpDir, "invoice.pdf");
|
|
2345
2340
|
const result = await server.tools.get("download-bytes-to-file").handler({
|
|
2346
2341
|
target: "/me/messages/m1/attachments/a1/$value",
|
|
@@ -2364,18 +2359,7 @@ describe("graph-tools", () => {
|
|
|
2364
2359
|
expect(stdioServer.tools.has("download-bytes-to-file")).toBe(true);
|
|
2365
2360
|
expect(stdioServer.tools.has("download-bytes")).toBe(true);
|
|
2366
2361
|
const httpServer = createMockServer();
|
|
2367
|
-
registerGraphTools(
|
|
2368
|
-
httpServer,
|
|
2369
|
-
{},
|
|
2370
|
-
false,
|
|
2371
|
-
void 0,
|
|
2372
|
-
false,
|
|
2373
|
-
void 0,
|
|
2374
|
-
false,
|
|
2375
|
-
[],
|
|
2376
|
-
void 0,
|
|
2377
|
-
true
|
|
2378
|
-
);
|
|
2362
|
+
registerGraphTools(httpServer, {}, { httpMode: true });
|
|
2379
2363
|
expect(httpServer.tools.has("download-bytes-to-file")).toBe(false);
|
|
2380
2364
|
expect(httpServer.tools.has("download-bytes")).toBe(true);
|
|
2381
2365
|
});
|
|
@@ -2730,16 +2714,11 @@ describe("graph-tools", () => {
|
|
|
2730
2714
|
};
|
|
2731
2715
|
const server = createMockServer();
|
|
2732
2716
|
const { registerGraphTools } = await loadModule();
|
|
2733
|
-
registerGraphTools(
|
|
2734
|
-
server,
|
|
2735
|
-
graphClient,
|
|
2736
|
-
false,
|
|
2737
|
-
void 0,
|
|
2738
|
-
false,
|
|
2717
|
+
registerGraphTools(server, graphClient, {
|
|
2739
2718
|
authManager,
|
|
2740
|
-
true,
|
|
2741
|
-
["user1@domain.com", "user2@domain.com"]
|
|
2742
|
-
);
|
|
2719
|
+
multiAccount: true,
|
|
2720
|
+
accountNames: ["user1@domain.com", "user2@domain.com"]
|
|
2721
|
+
});
|
|
2743
2722
|
const { requestContext } = await import("../request-context.js");
|
|
2744
2723
|
const tool = server.tools.get("get-download-url");
|
|
2745
2724
|
const bearer = makeJwt({ upn: "user1@domain.com" });
|
|
@@ -2790,17 +2769,9 @@ describe("graph-tools", () => {
|
|
|
2790
2769
|
];
|
|
2791
2770
|
const server = createMockServer();
|
|
2792
2771
|
const { registerGraphTools } = await loadModule();
|
|
2793
|
-
registerGraphTools(
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
false,
|
|
2797
|
-
void 0,
|
|
2798
|
-
false,
|
|
2799
|
-
void 0,
|
|
2800
|
-
false,
|
|
2801
|
-
[],
|
|
2802
|
-
"Mail.Read"
|
|
2803
|
-
);
|
|
2772
|
+
registerGraphTools(server, createMockGraphClient(), {
|
|
2773
|
+
allowedScopes: "Mail.Read"
|
|
2774
|
+
});
|
|
2804
2775
|
expect(server.tools.has("list-mail-messages")).toBe(true);
|
|
2805
2776
|
expect(server.tools.has("list-calendar-events")).toBe(false);
|
|
2806
2777
|
});
|
|
@@ -2825,17 +2796,9 @@ describe("graph-tools", () => {
|
|
|
2825
2796
|
];
|
|
2826
2797
|
const server = createMockServer();
|
|
2827
2798
|
const { registerGraphTools } = await loadModule();
|
|
2828
|
-
registerGraphTools(
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
false,
|
|
2832
|
-
void 0,
|
|
2833
|
-
false,
|
|
2834
|
-
void 0,
|
|
2835
|
-
false,
|
|
2836
|
-
[],
|
|
2837
|
-
"Mail.Read"
|
|
2838
|
-
);
|
|
2799
|
+
registerGraphTools(server, createMockGraphClient(), {
|
|
2800
|
+
allowedScopes: "Mail.Read"
|
|
2801
|
+
});
|
|
2839
2802
|
const handler = server.server._requestHandlers.get("tools/call");
|
|
2840
2803
|
await expect(
|
|
2841
2804
|
handler?.(
|
|
@@ -2896,17 +2859,7 @@ describe("graph-tools", () => {
|
|
|
2896
2859
|
];
|
|
2897
2860
|
const server = createMockServer();
|
|
2898
2861
|
const { registerDiscoveryTools } = await loadModule();
|
|
2899
|
-
registerDiscoveryTools(
|
|
2900
|
-
server,
|
|
2901
|
-
{},
|
|
2902
|
-
false,
|
|
2903
|
-
false,
|
|
2904
|
-
void 0,
|
|
2905
|
-
false,
|
|
2906
|
-
[],
|
|
2907
|
-
void 0,
|
|
2908
|
-
"Mail.Read"
|
|
2909
|
-
);
|
|
2862
|
+
registerDiscoveryTools(server, {}, { allowedScopes: "Mail.Read" });
|
|
2910
2863
|
const result = await server.tools.get("search-tools").handler({ limit: 50 });
|
|
2911
2864
|
const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
|
|
2912
2865
|
expect(found).toContain("list-mail-messages");
|
|
@@ -2933,17 +2886,7 @@ describe("graph-tools", () => {
|
|
|
2933
2886
|
];
|
|
2934
2887
|
const server = createMockServer();
|
|
2935
2888
|
const { registerDiscoveryTools } = await loadModule();
|
|
2936
|
-
registerDiscoveryTools(
|
|
2937
|
-
server,
|
|
2938
|
-
{},
|
|
2939
|
-
false,
|
|
2940
|
-
false,
|
|
2941
|
-
void 0,
|
|
2942
|
-
false,
|
|
2943
|
-
[],
|
|
2944
|
-
void 0,
|
|
2945
|
-
"Mail.Read"
|
|
2946
|
-
);
|
|
2889
|
+
registerDiscoveryTools(server, {}, { allowedScopes: "Mail.Read" });
|
|
2947
2890
|
const result = await server.tools.get("execute-tool").handler({
|
|
2948
2891
|
tool_name: "get-drive-item",
|
|
2949
2892
|
parameters: { driveId: "drive-1", driveItemId: "item-2" }
|
|
@@ -2985,17 +2928,7 @@ describe("graph-tools", () => {
|
|
|
2985
2928
|
];
|
|
2986
2929
|
const server = createMockServer();
|
|
2987
2930
|
const { registerDiscoveryTools } = await loadModule();
|
|
2988
|
-
registerDiscoveryTools(
|
|
2989
|
-
server,
|
|
2990
|
-
{},
|
|
2991
|
-
false,
|
|
2992
|
-
false,
|
|
2993
|
-
void 0,
|
|
2994
|
-
false,
|
|
2995
|
-
[],
|
|
2996
|
-
void 0,
|
|
2997
|
-
"Mail.Read"
|
|
2998
|
-
);
|
|
2931
|
+
registerDiscoveryTools(server, {}, { allowedScopes: "Mail.Read" });
|
|
2999
2932
|
const handler = server.server._requestHandlers.get("tools/call");
|
|
3000
2933
|
await expect(
|
|
3001
2934
|
handler?.(
|
|
@@ -3121,7 +3054,7 @@ describe("graph-tools", () => {
|
|
|
3121
3054
|
];
|
|
3122
3055
|
const server = createMockServer();
|
|
3123
3056
|
const { registerDiscoveryTools } = await loadModule();
|
|
3124
|
-
registerDiscoveryTools(server, {},
|
|
3057
|
+
registerDiscoveryTools(server, {}, { enabledTools: "mail" });
|
|
3125
3058
|
const result = await server.tools.get("search-tools").handler({ limit: 50 });
|
|
3126
3059
|
const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
|
|
3127
3060
|
expect(found).toContain("list-mail-messages");
|
|
@@ -3157,16 +3090,7 @@ describe("graph-tools", () => {
|
|
|
3157
3090
|
];
|
|
3158
3091
|
const server = createMockServer();
|
|
3159
3092
|
const { registerDiscoveryTools } = await loadModule();
|
|
3160
|
-
registerDiscoveryTools(
|
|
3161
|
-
server,
|
|
3162
|
-
{},
|
|
3163
|
-
false,
|
|
3164
|
-
false,
|
|
3165
|
-
void 0,
|
|
3166
|
-
false,
|
|
3167
|
-
[],
|
|
3168
|
-
"^list-mail-messages$"
|
|
3169
|
-
);
|
|
3093
|
+
registerDiscoveryTools(server, {}, { enabledTools: "^list-mail-messages$" });
|
|
3170
3094
|
const result = await server.tools.get("execute-tool").handler({
|
|
3171
3095
|
tool_name: "get-drive-item",
|
|
3172
3096
|
parameters: { driveId: "drive-1", driveItemId: "item-2" }
|
|
@@ -3191,16 +3115,7 @@ describe("graph-tools", () => {
|
|
|
3191
3115
|
mockEndpointsJson = [];
|
|
3192
3116
|
const server = createMockServer();
|
|
3193
3117
|
const { registerDiscoveryTools } = await loadModule();
|
|
3194
|
-
registerDiscoveryTools(
|
|
3195
|
-
server,
|
|
3196
|
-
{},
|
|
3197
|
-
false,
|
|
3198
|
-
false,
|
|
3199
|
-
void 0,
|
|
3200
|
-
false,
|
|
3201
|
-
[],
|
|
3202
|
-
"^download-bytes$"
|
|
3203
|
-
);
|
|
3118
|
+
registerDiscoveryTools(server, {}, { enabledTools: "^download-bytes$" });
|
|
3204
3119
|
const result = await server.tools.get("search-tools").handler({ limit: 50 });
|
|
3205
3120
|
const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
|
|
3206
3121
|
expect(found).toContain("download-bytes");
|
|
@@ -3211,16 +3126,7 @@ describe("graph-tools", () => {
|
|
|
3211
3126
|
mockEndpointsJson = [];
|
|
3212
3127
|
const server = createMockServer();
|
|
3213
3128
|
const { registerDiscoveryTools } = await loadModule();
|
|
3214
|
-
registerDiscoveryTools(
|
|
3215
|
-
server,
|
|
3216
|
-
{},
|
|
3217
|
-
false,
|
|
3218
|
-
false,
|
|
3219
|
-
void 0,
|
|
3220
|
-
false,
|
|
3221
|
-
[],
|
|
3222
|
-
"[invalid"
|
|
3223
|
-
);
|
|
3129
|
+
registerDiscoveryTools(server, {}, { enabledTools: "[invalid" });
|
|
3224
3130
|
const result = await server.tools.get("search-tools").handler({ limit: 50 });
|
|
3225
3131
|
const found = JSON.parse(result.content[0].text).tools.map((t) => t.name);
|
|
3226
3132
|
expect(found).toContain("download-bytes");
|
|
@@ -3233,7 +3139,7 @@ describe("graph-tools", () => {
|
|
|
3233
3139
|
mockEndpointsJson = [];
|
|
3234
3140
|
const server = createMockServer();
|
|
3235
3141
|
const { registerGraphTools } = await loadModule();
|
|
3236
|
-
registerGraphTools(server, {}, true);
|
|
3142
|
+
registerGraphTools(server, {}, { readOnly: true });
|
|
3237
3143
|
expect(server.tools.has("download-bytes")).toBe(true);
|
|
3238
3144
|
expect(server.tools.has("parse-teams-url")).toBe(true);
|
|
3239
3145
|
});
|
|
@@ -3485,6 +3391,387 @@ describe("graph-tools", () => {
|
|
|
3485
3391
|
joinWebUrl: "https://teams/x"
|
|
3486
3392
|
});
|
|
3487
3393
|
});
|
|
3394
|
+
it("restricts list-users selects and responses to the configured user fields", async () => {
|
|
3395
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-users", path: "/users" }));
|
|
3396
|
+
mockEndpointsJson = [
|
|
3397
|
+
makeConfig({ toolName: "list-users", pathPattern: "/users", scopes: ["User.Read.All"] })
|
|
3398
|
+
];
|
|
3399
|
+
const graphClient = createMockGraphClient([
|
|
3400
|
+
{
|
|
3401
|
+
content: [
|
|
3402
|
+
{
|
|
3403
|
+
type: "text",
|
|
3404
|
+
text: JSON.stringify({
|
|
3405
|
+
value: [
|
|
3406
|
+
{ id: "1", displayName: "Carlos", mail: "carlos@example.com", jobTitle: "CEO" }
|
|
3407
|
+
]
|
|
3408
|
+
})
|
|
3409
|
+
}
|
|
3410
|
+
]
|
|
3411
|
+
}
|
|
3412
|
+
]);
|
|
3413
|
+
const server = createMockServer();
|
|
3414
|
+
const { registerGraphTools } = await loadModule();
|
|
3415
|
+
registerGraphTools(server, graphClient, {
|
|
3416
|
+
userFields: "id,displayName,mail"
|
|
3417
|
+
});
|
|
3418
|
+
const result = await server.tools.get("list-users").handler({
|
|
3419
|
+
select: "id,displayName,mail,jobTitle"
|
|
3420
|
+
});
|
|
3421
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).toContain("$select=id,displayName,mail");
|
|
3422
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).not.toContain("jobTitle");
|
|
3423
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({
|
|
3424
|
+
id: "1",
|
|
3425
|
+
displayName: "Carlos",
|
|
3426
|
+
mail: "carlos@example.com"
|
|
3427
|
+
});
|
|
3428
|
+
});
|
|
3429
|
+
it("adds the configured fields when list-users is called without select", async () => {
|
|
3430
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-users", path: "/users" }));
|
|
3431
|
+
mockEndpointsJson = [
|
|
3432
|
+
makeConfig({ toolName: "list-users", pathPattern: "/users", scopes: ["User.Read.All"] })
|
|
3433
|
+
];
|
|
3434
|
+
const graphClient = createMockGraphClient([
|
|
3435
|
+
{
|
|
3436
|
+
content: [
|
|
3437
|
+
{
|
|
3438
|
+
type: "text",
|
|
3439
|
+
text: JSON.stringify({
|
|
3440
|
+
value: [{ id: "1", displayName: "Carlos", jobTitle: "CEO" }]
|
|
3441
|
+
})
|
|
3442
|
+
}
|
|
3443
|
+
]
|
|
3444
|
+
}
|
|
3445
|
+
]);
|
|
3446
|
+
const server = createMockServer();
|
|
3447
|
+
const { registerGraphTools } = await loadModule();
|
|
3448
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3449
|
+
const result = await server.tools.get("list-users").handler({});
|
|
3450
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).toContain("$select=id,displayName");
|
|
3451
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({
|
|
3452
|
+
id: "1",
|
|
3453
|
+
displayName: "Carlos"
|
|
3454
|
+
});
|
|
3455
|
+
});
|
|
3456
|
+
it("blocks expanded user data outside the list-users field boundary", async () => {
|
|
3457
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-users", path: "/users" }));
|
|
3458
|
+
mockEndpointsJson = [
|
|
3459
|
+
makeConfig({ toolName: "list-users", pathPattern: "/users", scopes: ["User.Read.All"] })
|
|
3460
|
+
];
|
|
3461
|
+
const graphClient = createMockGraphClient([
|
|
3462
|
+
{
|
|
3463
|
+
content: [
|
|
3464
|
+
{
|
|
3465
|
+
type: "text",
|
|
3466
|
+
text: JSON.stringify({
|
|
3467
|
+
value: [
|
|
3468
|
+
{
|
|
3469
|
+
id: "1",
|
|
3470
|
+
displayName: "Carlos",
|
|
3471
|
+
manager: { displayName: "Manager", jobTitle: "CEO" }
|
|
3472
|
+
}
|
|
3473
|
+
]
|
|
3474
|
+
})
|
|
3475
|
+
}
|
|
3476
|
+
]
|
|
3477
|
+
}
|
|
3478
|
+
]);
|
|
3479
|
+
const server = createMockServer();
|
|
3480
|
+
const { registerGraphTools } = await loadModule();
|
|
3481
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3482
|
+
const result = await server.tools.get("list-users").handler({
|
|
3483
|
+
select: "id,displayName",
|
|
3484
|
+
expand: "manager($select=displayName,jobTitle)"
|
|
3485
|
+
});
|
|
3486
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).not.toContain("$expand");
|
|
3487
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({
|
|
3488
|
+
id: "1",
|
|
3489
|
+
displayName: "Carlos"
|
|
3490
|
+
});
|
|
3491
|
+
});
|
|
3492
|
+
async function runListUsers(userFields, args, graphBody) {
|
|
3493
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-users", path: "/users" }));
|
|
3494
|
+
mockEndpointsJson = [
|
|
3495
|
+
makeConfig({ toolName: "list-users", pathPattern: "/users", scopes: ["User.Read.All"] })
|
|
3496
|
+
];
|
|
3497
|
+
const graphClient = createMockGraphClient([
|
|
3498
|
+
{ content: [{ type: "text", text: JSON.stringify(graphBody) }] }
|
|
3499
|
+
]);
|
|
3500
|
+
const server = createMockServer();
|
|
3501
|
+
const { registerGraphTools } = await loadModule();
|
|
3502
|
+
registerGraphTools(server, graphClient, { userFields });
|
|
3503
|
+
const result = await server.tools.get("list-users").handler(args);
|
|
3504
|
+
return { result, graphClient };
|
|
3505
|
+
}
|
|
3506
|
+
it("still projects when the requested fields are all disallowed", async () => {
|
|
3507
|
+
const { result, graphClient } = await runListUsers(
|
|
3508
|
+
"id,displayName",
|
|
3509
|
+
{ select: "jobTitle,employeeId" },
|
|
3510
|
+
{ value: [{ id: "1", displayName: "Carlos", jobTitle: "CEO" }] }
|
|
3511
|
+
);
|
|
3512
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).toContain("$select=id,displayName");
|
|
3513
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({
|
|
3514
|
+
id: "1",
|
|
3515
|
+
displayName: "Carlos"
|
|
3516
|
+
});
|
|
3517
|
+
});
|
|
3518
|
+
it("drops id when the allowlist does not name it", async () => {
|
|
3519
|
+
const { result } = await runListUsers(
|
|
3520
|
+
"displayName",
|
|
3521
|
+
{},
|
|
3522
|
+
{ value: [{ id: "1", displayName: "Carlos", jobTitle: "CEO" }] }
|
|
3523
|
+
);
|
|
3524
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({ displayName: "Carlos" });
|
|
3525
|
+
});
|
|
3526
|
+
it("keeps the pagination envelope while enforcing the boundary", async () => {
|
|
3527
|
+
const { result } = await runListUsers(
|
|
3528
|
+
"displayName",
|
|
3529
|
+
{},
|
|
3530
|
+
{
|
|
3531
|
+
"@odata.nextLink": "https://graph/next",
|
|
3532
|
+
value: [{ id: "1", displayName: "Carlos", jobTitle: "CEO" }]
|
|
3533
|
+
}
|
|
3534
|
+
);
|
|
3535
|
+
expect(JSON.parse(result.content[0].text)).toEqual({
|
|
3536
|
+
"@odata.nextLink": "https://graph/next",
|
|
3537
|
+
value: [{ displayName: "Carlos" }]
|
|
3538
|
+
});
|
|
3539
|
+
});
|
|
3540
|
+
it("fails closed when Graph returns none of the allowlisted fields", async () => {
|
|
3541
|
+
const { result } = await runListUsers(
|
|
3542
|
+
"displayName",
|
|
3543
|
+
{},
|
|
3544
|
+
{ value: [{ id: "1", jobTitle: "CEO", employeeId: "E-1" }] }
|
|
3545
|
+
);
|
|
3546
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({});
|
|
3547
|
+
});
|
|
3548
|
+
it("refuses to register with an allowlist that names no fields", async () => {
|
|
3549
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-users", path: "/users" }));
|
|
3550
|
+
mockEndpointsJson = [
|
|
3551
|
+
makeConfig({ toolName: "list-users", pathPattern: "/users", scopes: ["User.Read.All"] })
|
|
3552
|
+
];
|
|
3553
|
+
const server = createMockServer();
|
|
3554
|
+
const { registerGraphTools } = await loadModule();
|
|
3555
|
+
expect(
|
|
3556
|
+
() => registerGraphTools(server, createMockGraphClient(), { userFields: " , ," })
|
|
3557
|
+
).toThrow(/names no fields/);
|
|
3558
|
+
});
|
|
3559
|
+
it("enforces the boundary through discovery mode execute-tool", async () => {
|
|
3560
|
+
mockEndpoints.push(makeEndpoint({ alias: "list-users", path: "/users" }));
|
|
3561
|
+
mockEndpointsJson = [
|
|
3562
|
+
makeConfig({ toolName: "list-users", pathPattern: "/users", scopes: ["User.Read.All"] })
|
|
3563
|
+
];
|
|
3564
|
+
const graphClient = createMockGraphClient([
|
|
3565
|
+
{
|
|
3566
|
+
content: [
|
|
3567
|
+
{
|
|
3568
|
+
type: "text",
|
|
3569
|
+
text: JSON.stringify({
|
|
3570
|
+
value: [{ id: "1", displayName: "Carlos", jobTitle: "CEO" }]
|
|
3571
|
+
})
|
|
3572
|
+
}
|
|
3573
|
+
]
|
|
3574
|
+
}
|
|
3575
|
+
]);
|
|
3576
|
+
const server = createMockServer();
|
|
3577
|
+
const { registerDiscoveryTools } = await loadModule();
|
|
3578
|
+
registerDiscoveryTools(server, graphClient, {
|
|
3579
|
+
userFields: "id,displayName"
|
|
3580
|
+
});
|
|
3581
|
+
const result = await server.tools.get("execute-tool").handler({
|
|
3582
|
+
tool_name: "list-users",
|
|
3583
|
+
parameters: { select: "id,displayName,jobTitle" }
|
|
3584
|
+
});
|
|
3585
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).toContain("$select=id,displayName");
|
|
3586
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).not.toContain("jobTitle");
|
|
3587
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({
|
|
3588
|
+
id: "1",
|
|
3589
|
+
displayName: "Carlos"
|
|
3590
|
+
});
|
|
3591
|
+
});
|
|
3592
|
+
it("projects a directory navigation that returns user profile data", async () => {
|
|
3593
|
+
mockEndpoints.push(
|
|
3594
|
+
makeEndpoint({ alias: "get-user-manager", path: "/users/:userId/manager" })
|
|
3595
|
+
);
|
|
3596
|
+
mockEndpointsJson = [
|
|
3597
|
+
makeConfig({
|
|
3598
|
+
toolName: "get-user-manager",
|
|
3599
|
+
pathPattern: "/users/{user-id}/manager",
|
|
3600
|
+
scopes: ["User.Read.All"]
|
|
3601
|
+
})
|
|
3602
|
+
];
|
|
3603
|
+
const graphClient = createMockGraphClient([
|
|
3604
|
+
{
|
|
3605
|
+
content: [
|
|
3606
|
+
{
|
|
3607
|
+
type: "text",
|
|
3608
|
+
text: JSON.stringify({
|
|
3609
|
+
id: "m1",
|
|
3610
|
+
displayName: "Manager",
|
|
3611
|
+
mail: "manager@example.com",
|
|
3612
|
+
jobTitle: "CEO",
|
|
3613
|
+
employeeId: "E-9"
|
|
3614
|
+
})
|
|
3615
|
+
}
|
|
3616
|
+
]
|
|
3617
|
+
}
|
|
3618
|
+
]);
|
|
3619
|
+
const server = createMockServer();
|
|
3620
|
+
const { registerGraphTools } = await loadModule();
|
|
3621
|
+
registerGraphTools(server, graphClient, {
|
|
3622
|
+
userFields: "id,displayName,mail"
|
|
3623
|
+
});
|
|
3624
|
+
const result = await server.tools.get("get-user-manager").handler({ userId: "abc" });
|
|
3625
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).not.toContain("$select");
|
|
3626
|
+
expect(JSON.parse(result.content[0].text)).toEqual({
|
|
3627
|
+
id: "m1",
|
|
3628
|
+
displayName: "Manager",
|
|
3629
|
+
mail: "manager@example.com"
|
|
3630
|
+
});
|
|
3631
|
+
});
|
|
3632
|
+
it("projects group members, which carry the same profile fields", async () => {
|
|
3633
|
+
mockEndpoints.push(
|
|
3634
|
+
makeEndpoint({ alias: "list-group-members", path: "/groups/:groupId/members" })
|
|
3635
|
+
);
|
|
3636
|
+
mockEndpointsJson = [
|
|
3637
|
+
makeConfig({
|
|
3638
|
+
toolName: "list-group-members",
|
|
3639
|
+
pathPattern: "/groups/{group-id}/members",
|
|
3640
|
+
scopes: ["GroupMember.Read.All"]
|
|
3641
|
+
})
|
|
3642
|
+
];
|
|
3643
|
+
const graphClient = createMockGraphClient([
|
|
3644
|
+
{
|
|
3645
|
+
content: [
|
|
3646
|
+
{
|
|
3647
|
+
type: "text",
|
|
3648
|
+
text: JSON.stringify({
|
|
3649
|
+
value: [{ id: "1", displayName: "Carlos", jobTitle: "CEO" }]
|
|
3650
|
+
})
|
|
3651
|
+
}
|
|
3652
|
+
]
|
|
3653
|
+
}
|
|
3654
|
+
]);
|
|
3655
|
+
const server = createMockServer();
|
|
3656
|
+
const { registerGraphTools } = await loadModule();
|
|
3657
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3658
|
+
const result = await server.tools.get("list-group-members").handler({ groupId: "g1" });
|
|
3659
|
+
expect(JSON.parse(result.content[0].text).value[0]).toEqual({
|
|
3660
|
+
id: "1",
|
|
3661
|
+
displayName: "Carlos"
|
|
3662
|
+
});
|
|
3663
|
+
});
|
|
3664
|
+
it.each([
|
|
3665
|
+
{ alias: "get-current-user", path: "/me", pathPattern: "/me", args: {} },
|
|
3666
|
+
{
|
|
3667
|
+
alias: "get-group",
|
|
3668
|
+
path: "/groups/:groupId",
|
|
3669
|
+
pathPattern: "/groups/{group-id}",
|
|
3670
|
+
args: { groupId: "g1" }
|
|
3671
|
+
}
|
|
3672
|
+
])("strips $expand from $alias while the field policy is active", async (endpoint) => {
|
|
3673
|
+
mockEndpoints.push(makeEndpoint({ alias: endpoint.alias, path: endpoint.path }));
|
|
3674
|
+
mockEndpointsJson = [
|
|
3675
|
+
makeConfig({
|
|
3676
|
+
toolName: endpoint.alias,
|
|
3677
|
+
pathPattern: endpoint.pathPattern,
|
|
3678
|
+
scopes: ["User.Read.All"]
|
|
3679
|
+
})
|
|
3680
|
+
];
|
|
3681
|
+
const graphClient = createMockGraphClient([
|
|
3682
|
+
{ content: [{ type: "text", text: JSON.stringify({ id: "1" }) }] }
|
|
3683
|
+
]);
|
|
3684
|
+
const server = createMockServer();
|
|
3685
|
+
const { registerGraphTools } = await loadModule();
|
|
3686
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3687
|
+
await server.tools.get(endpoint.alias).handler({
|
|
3688
|
+
...endpoint.args,
|
|
3689
|
+
expand: "manager($select=displayName,jobTitle)"
|
|
3690
|
+
});
|
|
3691
|
+
expect(graphClient.graphRequest.mock.calls[0][0]).not.toContain("$expand");
|
|
3692
|
+
});
|
|
3693
|
+
it("restricts a graph-batch subrequest that reads the users surface", async () => {
|
|
3694
|
+
mockEndpoints.push(
|
|
3695
|
+
makeEndpoint({
|
|
3696
|
+
alias: "graph-batch",
|
|
3697
|
+
method: "post",
|
|
3698
|
+
path: "/$batch",
|
|
3699
|
+
parameters: [{ name: "body", type: "Body", schema: z.any() }]
|
|
3700
|
+
})
|
|
3701
|
+
);
|
|
3702
|
+
mockEndpointsJson = [
|
|
3703
|
+
makeConfig({ toolName: "graph-batch", pathPattern: "/$batch", method: "post" })
|
|
3704
|
+
];
|
|
3705
|
+
const graphClient = createMockGraphClient([
|
|
3706
|
+
{
|
|
3707
|
+
content: [
|
|
3708
|
+
{
|
|
3709
|
+
type: "text",
|
|
3710
|
+
text: JSON.stringify({
|
|
3711
|
+
responses: [
|
|
3712
|
+
{
|
|
3713
|
+
id: "1",
|
|
3714
|
+
status: 200,
|
|
3715
|
+
body: {
|
|
3716
|
+
value: [{ id: "1", displayName: "Carlos", employeeId: "E-1" }]
|
|
3717
|
+
}
|
|
3718
|
+
},
|
|
3719
|
+
{ id: "2", status: 200, body: { id: "m1", subject: "Keep me" } },
|
|
3720
|
+
{ id: "3", status: 200, body: { id: "g1", displayName: "Team" } }
|
|
3721
|
+
]
|
|
3722
|
+
})
|
|
3723
|
+
}
|
|
3724
|
+
]
|
|
3725
|
+
}
|
|
3726
|
+
]);
|
|
3727
|
+
const server = createMockServer();
|
|
3728
|
+
const { registerGraphTools } = await loadModule();
|
|
3729
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3730
|
+
const result = await server.tools.get("graph-batch").handler({
|
|
3731
|
+
body: {
|
|
3732
|
+
requests: [
|
|
3733
|
+
{ id: "1", method: "GET", url: "/users?$select=id,displayName,employeeId" },
|
|
3734
|
+
{ id: "2", method: "GET", url: "/me/messages/m1?$select=id,subject" },
|
|
3735
|
+
{ id: "3", method: "GET", url: "/groups/g1?$expand=members($select=mail)" }
|
|
3736
|
+
]
|
|
3737
|
+
}
|
|
3738
|
+
});
|
|
3739
|
+
const sentBody = JSON.parse(graphClient.graphRequest.mock.calls[0][1].body);
|
|
3740
|
+
expect(sentBody.requests[0].url).toBe("/users?$select=id,displayName");
|
|
3741
|
+
expect(sentBody.requests[1].url).toBe("/me/messages/m1?$select=id,subject");
|
|
3742
|
+
expect(sentBody.requests[2].url).toBe("/groups/g1");
|
|
3743
|
+
const responses = JSON.parse(result.content[0].text).responses;
|
|
3744
|
+
expect(responses[0].body.value[0]).toEqual({ id: "1", displayName: "Carlos" });
|
|
3745
|
+
expect(responses[1].body).toEqual({ id: "m1", subject: "Keep me" });
|
|
3746
|
+
});
|
|
3747
|
+
it("refuses a byte-passthrough read of the users surface", async () => {
|
|
3748
|
+
mockEndpoints.length = 0;
|
|
3749
|
+
mockEndpointsJson = [];
|
|
3750
|
+
const graphClient = createMockGraphClient();
|
|
3751
|
+
const server = createMockServer();
|
|
3752
|
+
const { registerGraphTools } = await loadModule();
|
|
3753
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3754
|
+
const result = await server.tools.get("download-bytes").handler({
|
|
3755
|
+
target: "/users?$select=employeeId"
|
|
3756
|
+
});
|
|
3757
|
+
expect(result.isError).toBe(true);
|
|
3758
|
+
expect(JSON.parse(result.content[0].text).error).toBe("user_fields_restricted");
|
|
3759
|
+
expect(graphClient.graphRequest).not.toHaveBeenCalled();
|
|
3760
|
+
});
|
|
3761
|
+
it("still allows byte reads below a user, such as a profile photo", async () => {
|
|
3762
|
+
mockEndpoints.length = 0;
|
|
3763
|
+
mockEndpointsJson = [];
|
|
3764
|
+
const graphClient = createMockGraphClient([
|
|
3765
|
+
{ content: [{ type: "text", text: JSON.stringify({ message: "OK!" }) }] }
|
|
3766
|
+
]);
|
|
3767
|
+
const server = createMockServer();
|
|
3768
|
+
const { registerGraphTools } = await loadModule();
|
|
3769
|
+
registerGraphTools(server, graphClient, { userFields: "id,displayName" });
|
|
3770
|
+
await server.tools.get("download-bytes").handler({
|
|
3771
|
+
target: "/users/abc/photo/$value"
|
|
3772
|
+
});
|
|
3773
|
+
expect(graphClient.graphRequest).toHaveBeenCalledTimes(1);
|
|
3774
|
+
});
|
|
3488
3775
|
it("leaves the body untouched when no select was passed", async () => {
|
|
3489
3776
|
const { result } = await run({});
|
|
3490
3777
|
expect(JSON.parse(result.content[0].text)).toEqual(untrimmed);
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@ import { Command, Option } from "commander";
|
|
|
2
2
|
import { readFileSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { getCombinedPresetPattern, listPresets, presetRequiresOrgMode } from "./tool-categories.js";
|
|
6
5
|
import { assertSignoffMarkersVisible } from "./lib/message-signoff.js";
|
|
6
|
+
import { getCombinedPresetPattern, listPresets, presetRequiresOrgMode } from "./tool-categories.js";
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const packageJsonPath = path.join(__dirname, "..", "package.json");
|
|
9
9
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
@@ -42,6 +42,9 @@ program.name("ms-365-mcp-server").description("Microsoft 365 MCP Server").versio
|
|
|
42
42
|
).option(
|
|
43
43
|
"--allowed-scopes <scopes>",
|
|
44
44
|
"Limit exposed tools to Graph scopes covered by this whitespace-separated allowlist"
|
|
45
|
+
).option(
|
|
46
|
+
"--user-fields <fields>",
|
|
47
|
+
"Allow only these comma-separated Microsoft Graph fields in user profile responses (/users and /users/{id})"
|
|
45
48
|
).option(
|
|
46
49
|
"--extra-scopes <scopes>",
|
|
47
50
|
"Append additional Graph scopes (whitespace-separated) to the token request, beyond those derived from enabled tools. Use with your own app registration (MS365_MCP_CLIENT_ID/SECRET) to request scopes the default app does not declare, then call the endpoints via graph-batch."
|
|
@@ -130,6 +133,15 @@ function parseArgs() {
|
|
|
130
133
|
);
|
|
131
134
|
process.exit(1);
|
|
132
135
|
}
|
|
136
|
+
if (options.userFields === void 0 && process.env.MS365_MCP_USER_FIELDS !== void 0) {
|
|
137
|
+
options.userFields = process.env.MS365_MCP_USER_FIELDS;
|
|
138
|
+
}
|
|
139
|
+
if (options.userFields !== void 0 && String(options.userFields).split(",").every((field) => field.trim() === "")) {
|
|
140
|
+
console.error(
|
|
141
|
+
"Error: --user-fields / MS365_MCP_USER_FIELDS was provided but names no fields. Provide one or more comma-separated Graph fields, or omit it to disable the user profile field boundary."
|
|
142
|
+
);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
133
145
|
if (options.extraScopes === void 0 && process.env.MS365_MCP_EXTRA_SCOPES !== void 0) {
|
|
134
146
|
options.extraScopes = process.env.MS365_MCP_EXTRA_SCOPES;
|
|
135
147
|
}
|
|
@@ -748,6 +748,7 @@ const microsoft_graph_customEmojiFromIdentitySet = z.object({
|
|
|
748
748
|
user: microsoft_graph_identity.optional()
|
|
749
749
|
}).passthrough();
|
|
750
750
|
const microsoft_graph_teamworkCustomEmoji = z.object({
|
|
751
|
+
id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
|
|
751
752
|
contentBytes: z.string().describe(
|
|
752
753
|
"The base64-encoded image content of the emoji. Supported formats include PNG and GIF."
|
|
753
754
|
).nullish(),
|