@softeria/ms-365-mcp-server 0.143.0 → 0.145.0

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.
@@ -3,14 +3,23 @@ import { z } from "zod";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
6
+ const loggerMock = vi.hoisted(() => ({
7
+ info: vi.fn(),
8
+ warn: vi.fn(),
9
+ error: vi.fn(),
10
+ debug: vi.fn()
11
+ }));
6
12
  vi.mock("../logger.js", () => ({
7
- default: {
8
- info: vi.fn(),
9
- warn: vi.fn(),
10
- error: vi.fn(),
11
- debug: vi.fn()
12
- }
13
+ default: loggerMock
13
14
  }));
15
+ const auditLogMock = vi.hoisted(() => vi.fn());
16
+ vi.mock("../audit-log.js", async () => {
17
+ const actual = await vi.importActual("../audit-log.js");
18
+ return {
19
+ ...actual,
20
+ auditLog: auditLogMock
21
+ };
22
+ });
14
23
  const mockEndpoints = [];
15
24
  vi.mock("../generated/client-beta.js", () => ({ api: { endpoints: [] } }));
16
25
  vi.mock("../generated/client.js", () => ({
@@ -89,16 +98,35 @@ async function loadModule() {
89
98
  const mod = await import("../graph-tools.js");
90
99
  return mod;
91
100
  }
92
- async function spyOnAuditLogger() {
93
- const { __testing } = await import("../audit-log.js");
94
- return vi.spyOn(__testing.auditLogger, "info").mockImplementation(() => __testing.auditLogger);
95
- }
96
101
  function createMockServer() {
97
102
  const tools = /* @__PURE__ */ new Map();
103
+ const requestHandlers = /* @__PURE__ */ new Map();
104
+ const installDefaultToolCallHandler = () => {
105
+ if (requestHandlers.has("tools/call")) return;
106
+ requestHandlers.set("tools/call", async (request) => {
107
+ const params = request.params;
108
+ const toolName = params?.name ?? "unknown";
109
+ const tool = tools.get(toolName);
110
+ if (!tool) {
111
+ throw new Error(`Tool ${toolName} not found`);
112
+ }
113
+ return tool.handler(params?.arguments ?? {});
114
+ });
115
+ };
116
+ const lowLevelServer = {
117
+ _requestHandlers: requestHandlers,
118
+ setRequestHandler: vi.fn(
119
+ (_schema, handler) => {
120
+ requestHandlers.set("tools/call", handler);
121
+ }
122
+ )
123
+ };
98
124
  return {
125
+ server: lowLevelServer,
99
126
  tool: vi.fn(
100
127
  (name, description, schema, annotations, handler) => {
101
128
  tools.set(name, { description, schema, handler });
129
+ installDefaultToolCallHandler();
102
130
  }
103
131
  ),
104
132
  registerTool: vi.fn(
@@ -108,6 +136,7 @@ function createMockServer() {
108
136
  schema: config.inputSchema?.shape ?? config.inputSchema,
109
137
  handler
110
138
  });
139
+ installDefaultToolCallHandler();
111
140
  }
112
141
  ),
113
142
  tools
@@ -123,6 +152,187 @@ describe("graph-tools", () => {
123
152
  mockEndpointsJson = [];
124
153
  vi.clearAllMocks();
125
154
  });
155
+ describe("audit outcome metadata", () => {
156
+ it("includes HTTP status on successful Graph tool calls", async () => {
157
+ const endpoint = makeEndpoint();
158
+ const config = makeConfig();
159
+ mockEndpoints.push(endpoint);
160
+ mockEndpointsJson = [config];
161
+ const graphClient = createMockGraphClient([
162
+ {
163
+ content: [{ type: "text", text: JSON.stringify({ value: [] }) }],
164
+ _meta: { http_status: 200 }
165
+ }
166
+ ]);
167
+ const server = createMockServer();
168
+ const { registerGraphTools } = await loadModule();
169
+ registerGraphTools(
170
+ server,
171
+ graphClient
172
+ );
173
+ await server.tools.get("test-tool").handler({});
174
+ expect(auditLogMock).toHaveBeenCalledWith(
175
+ expect.objectContaining({
176
+ event: "tool.call",
177
+ tool: "test-tool",
178
+ status: "success",
179
+ http_status: 200
180
+ })
181
+ );
182
+ });
183
+ it("includes HTTP status and Graph error code on failed Graph tool calls", async () => {
184
+ const endpoint = makeEndpoint();
185
+ const config = makeConfig();
186
+ mockEndpoints.push(endpoint);
187
+ mockEndpointsJson = [config];
188
+ const graphClient = createMockGraphClient([
189
+ {
190
+ content: [
191
+ {
192
+ type: "text",
193
+ text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
194
+ }
195
+ ],
196
+ isError: true,
197
+ _meta: { http_status: 403, error_code: "accessDenied" }
198
+ }
199
+ ]);
200
+ const server = createMockServer();
201
+ const { registerGraphTools } = await loadModule();
202
+ registerGraphTools(server, graphClient);
203
+ await server.tools.get("test-tool").handler({});
204
+ expect(auditLogMock).toHaveBeenCalledWith(
205
+ expect.objectContaining({
206
+ event: "tool.call",
207
+ tool: "test-tool",
208
+ status: "error",
209
+ http_status: 403,
210
+ error_code: "accessDenied"
211
+ })
212
+ );
213
+ });
214
+ it("includes HTTP status on utility tool calls that reach Graph", async () => {
215
+ mockEndpoints.length = 0;
216
+ mockEndpointsJson = [];
217
+ const graphClient = {
218
+ graphRequest: vi.fn().mockResolvedValue({
219
+ content: [
220
+ {
221
+ type: "text",
222
+ text: JSON.stringify({
223
+ contentType: "image/jpeg",
224
+ encoding: "base64",
225
+ contentBytes: "aGk="
226
+ })
227
+ }
228
+ ],
229
+ _meta: { http_status: 200 }
230
+ })
231
+ };
232
+ const server = createMockServer();
233
+ const { registerGraphTools } = await loadModule();
234
+ registerGraphTools(server, graphClient);
235
+ await server.tools.get("download-bytes").handler({ target: "/me/photo/$value" });
236
+ expect(auditLogMock).toHaveBeenCalledWith(
237
+ expect.objectContaining({
238
+ event: "tool.call",
239
+ tool: "download-bytes",
240
+ status: "success",
241
+ http_method: "GET",
242
+ http_status: 200
243
+ })
244
+ );
245
+ });
246
+ it("includes HTTP status and Graph error code on failed utility Graph calls", async () => {
247
+ mockEndpoints.length = 0;
248
+ mockEndpointsJson = [];
249
+ const graphClient = {
250
+ graphRequest: vi.fn().mockResolvedValue({
251
+ content: [
252
+ {
253
+ type: "text",
254
+ text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
255
+ }
256
+ ],
257
+ isError: true,
258
+ _meta: { http_status: 403, error_code: "accessDenied" }
259
+ })
260
+ };
261
+ const server = createMockServer();
262
+ const { registerGraphTools } = await loadModule();
263
+ registerGraphTools(server, graphClient);
264
+ await server.tools.get("download-bytes").handler({ target: "/me/photo/$value" });
265
+ expect(auditLogMock).toHaveBeenCalledWith(
266
+ expect.objectContaining({
267
+ event: "tool.call",
268
+ tool: "download-bytes",
269
+ status: "error",
270
+ http_status: 403,
271
+ error_code: "accessDenied"
272
+ })
273
+ );
274
+ });
275
+ it("copies Graph batch outcome metadata into audit events", async () => {
276
+ const endpoint = makeEndpoint({
277
+ method: "post",
278
+ path: "/$batch",
279
+ alias: "graph-batch",
280
+ parameters: [{ name: "body", type: "Body", schema: z.object({}).passthrough() }]
281
+ });
282
+ const config = makeConfig({
283
+ pathPattern: "/$batch",
284
+ method: "post",
285
+ toolName: "graph-batch"
286
+ });
287
+ mockEndpoints.push(endpoint);
288
+ mockEndpointsJson = [config];
289
+ const graphClient = createMockGraphClient([
290
+ {
291
+ content: [
292
+ {
293
+ type: "text",
294
+ text: JSON.stringify({
295
+ responses: [
296
+ { id: "1", status: 200, body: { id: "message-1" } },
297
+ {
298
+ id: "2",
299
+ status: 403,
300
+ body: { error: { code: "accessDenied", message: "Access denied" } }
301
+ }
302
+ ]
303
+ })
304
+ }
305
+ ],
306
+ _meta: {
307
+ http_status: 200,
308
+ graph_batch_subrequest_count: 2,
309
+ graph_batch_http_status_counts: { "200": 1, "403": 1 },
310
+ graph_batch_error_code_counts: { accessDenied: 1 }
311
+ }
312
+ }
313
+ ]);
314
+ const server = createMockServer();
315
+ const { registerGraphTools } = await loadModule();
316
+ registerGraphTools(
317
+ server,
318
+ graphClient
319
+ );
320
+ await server.tools.get("graph-batch").handler({
321
+ body: { requests: [{ id: "1", method: "GET", url: "/me" }] }
322
+ });
323
+ expect(auditLogMock).toHaveBeenCalledWith(
324
+ expect.objectContaining({
325
+ event: "tool.call",
326
+ tool: "graph-batch",
327
+ status: "success",
328
+ http_status: 200,
329
+ graph_batch_subrequest_count: 2,
330
+ graph_batch_http_status_counts: { "200": 1, "403": 1 },
331
+ graph_batch_error_code_counts: { accessDenied: 1 }
332
+ })
333
+ );
334
+ });
335
+ });
126
336
  describe("$count advanced query mode", () => {
127
337
  it("should set ConsistencyLevel: eventual header when $count=true", async () => {
128
338
  const endpoint = makeEndpoint();
@@ -166,12 +376,11 @@ describe("graph-tools", () => {
166
376
  const server = createMockServer();
167
377
  const { registerGraphTools } = await loadModule();
168
378
  registerGraphTools(server, graphClient);
169
- const auditSpy = await spyOnAuditLogger();
170
379
  await server.tools.get("get-drive-item").handler({
171
380
  driveId: "drive-1",
172
381
  driveItemId: "item-2"
173
382
  });
174
- expect(auditSpy).toHaveBeenCalledWith(
383
+ expect(auditLogMock).toHaveBeenCalledWith(
175
384
  expect.objectContaining({
176
385
  event: "tool.call",
177
386
  tool: "get-drive-item",
@@ -182,7 +391,6 @@ describe("graph-tools", () => {
182
391
  }
183
392
  })
184
393
  );
185
- auditSpy.mockRestore();
186
394
  });
187
395
  it("adds target_resource to failed generated Graph tool audit events", async () => {
188
396
  const endpoint = makeEndpoint({
@@ -210,13 +418,12 @@ describe("graph-tools", () => {
210
418
  server,
211
419
  graphClient
212
420
  );
213
- const auditSpy = await spyOnAuditLogger();
214
421
  const result = await server.tools.get("get-drive-item").handler({
215
422
  driveId: "drive-1",
216
423
  driveItemId: "item-2"
217
424
  });
218
425
  expect(result.isError).toBe(true);
219
- expect(auditSpy).toHaveBeenCalledWith(
426
+ expect(auditLogMock).toHaveBeenCalledWith(
220
427
  expect.objectContaining({
221
428
  event: "tool.call",
222
429
  tool: "get-drive-item",
@@ -228,7 +435,6 @@ describe("graph-tools", () => {
228
435
  }
229
436
  })
230
437
  );
231
- auditSpy.mockRestore();
232
438
  });
233
439
  it("derives target_resource from generic ID path parameters", async () => {
234
440
  const endpoint = makeEndpoint({
@@ -248,11 +454,10 @@ describe("graph-tools", () => {
248
454
  const server = createMockServer();
249
455
  const { registerGraphTools } = await loadModule();
250
456
  registerGraphTools(server, graphClient);
251
- const auditSpy = await spyOnAuditLogger();
252
457
  await server.tools.get("get-mail-message").handler({
253
458
  messageId: "message-1"
254
459
  });
255
- expect(auditSpy).toHaveBeenCalledWith(
460
+ expect(auditLogMock).toHaveBeenCalledWith(
256
461
  expect.objectContaining({
257
462
  event: "tool.call",
258
463
  tool: "get-mail-message",
@@ -263,7 +468,6 @@ describe("graph-tools", () => {
263
468
  }
264
469
  })
265
470
  );
266
- auditSpy.mockRestore();
267
471
  });
268
472
  it("omits target_resource when an ID path parameter is missing", async () => {
269
473
  const endpoint = makeEndpoint({
@@ -287,18 +491,16 @@ describe("graph-tools", () => {
287
491
  const server = createMockServer();
288
492
  const { registerGraphTools } = await loadModule();
289
493
  registerGraphTools(server, graphClient);
290
- const auditSpy = await spyOnAuditLogger();
291
494
  await server.tools.get("get-drive-item").handler({
292
495
  driveId: "drive-1"
293
496
  });
294
- const [payload] = auditSpy.mock.calls[0];
497
+ const [payload] = auditLogMock.mock.calls[0];
295
498
  expect(payload).toMatchObject({
296
499
  event: "tool.call",
297
500
  tool: "get-drive-item",
298
501
  status: "success"
299
502
  });
300
503
  expect(payload).not.toHaveProperty("target_resource");
301
- auditSpy.mockRestore();
302
504
  });
303
505
  it("omits SharePoint path parameters from target_resource", async () => {
304
506
  const endpoint = makeEndpoint({
@@ -322,12 +524,11 @@ describe("graph-tools", () => {
322
524
  const server = createMockServer();
323
525
  const { registerGraphTools } = await loadModule();
324
526
  registerGraphTools(server, graphClient);
325
- const auditSpy = await spyOnAuditLogger();
326
527
  await server.tools.get("get-sharepoint-site-by-path").handler({
327
528
  siteId: "contoso.sharepoint.com",
328
529
  path: "/sites/Finance"
329
530
  });
330
- expect(auditSpy).toHaveBeenCalledWith(
531
+ expect(auditLogMock).toHaveBeenCalledWith(
331
532
  expect.objectContaining({
332
533
  event: "tool.call",
333
534
  tool: "get-sharepoint-site-by-path",
@@ -338,9 +539,8 @@ describe("graph-tools", () => {
338
539
  }
339
540
  })
340
541
  );
341
- const [payload] = auditSpy.mock.calls[0];
542
+ const [payload] = auditLogMock.mock.calls[0];
342
543
  expect(JSON.stringify(payload)).not.toContain("Finance");
343
- auditSpy.mockRestore();
344
544
  });
345
545
  it("omits target_resource for generated broad list/search audit events", async () => {
346
546
  const endpoint = makeEndpoint({
@@ -359,16 +559,14 @@ describe("graph-tools", () => {
359
559
  const server = createMockServer();
360
560
  const { registerGraphTools } = await loadModule();
361
561
  registerGraphTools(server, graphClient);
362
- const auditSpy = await spyOnAuditLogger();
363
562
  await server.tools.get("list-mail-messages").handler({ search: "budget" });
364
- const [payload] = auditSpy.mock.calls[0];
563
+ const [payload] = auditLogMock.mock.calls[0];
365
564
  expect(payload).toMatchObject({
366
565
  event: "tool.call",
367
566
  tool: "list-mail-messages",
368
567
  status: "success"
369
568
  });
370
569
  expect(payload).not.toHaveProperty("target_resource");
371
- auditSpy.mockRestore();
372
570
  });
373
571
  });
374
572
  describe("fetchAllPages pagination", () => {
@@ -411,6 +609,55 @@ describe("graph-tools", () => {
411
609
  expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
412
610
  expect(parsed["@odata.nextLink"]).toBeUndefined();
413
611
  });
612
+ it("returns and audits a later-page Graph error instead of partial success", async () => {
613
+ const endpoint = makeEndpoint();
614
+ const config = makeConfig();
615
+ mockEndpoints.push(endpoint);
616
+ mockEndpointsJson = [config];
617
+ const graphClient = createMockGraphClient([
618
+ {
619
+ content: [
620
+ {
621
+ type: "text",
622
+ text: JSON.stringify({
623
+ value: [{ id: "1" }],
624
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=1"
625
+ })
626
+ }
627
+ ],
628
+ _meta: { http_status: 200 }
629
+ },
630
+ {
631
+ content: [
632
+ {
633
+ type: "text",
634
+ text: JSON.stringify({ error: "Microsoft Graph API error: 429 Too Many Requests" })
635
+ }
636
+ ],
637
+ isError: true,
638
+ _meta: { http_status: 429, error_code: "tooManyRequests" }
639
+ }
640
+ ]);
641
+ const server = createMockServer();
642
+ const { registerGraphTools } = await loadModule();
643
+ registerGraphTools(server, graphClient);
644
+ const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
645
+ expect(result.isError).toBe(true);
646
+ expect(JSON.parse(result.content[0].text).error).toContain("429 Too Many Requests");
647
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(2);
648
+ expect(loggerMock.error).not.toHaveBeenCalledWith(
649
+ expect.stringContaining("Error during pagination")
650
+ );
651
+ expect(auditLogMock).toHaveBeenCalledWith(
652
+ expect.objectContaining({
653
+ event: "tool.call",
654
+ tool: "test-tool",
655
+ status: "error",
656
+ http_status: 429,
657
+ error_code: "tooManyRequests"
658
+ })
659
+ );
660
+ });
414
661
  it("merges all pages under --toon and encodes the combined result once (#560)", async () => {
415
662
  const endpoint = makeEndpoint();
416
663
  const config = makeConfig();
@@ -1021,7 +1268,8 @@ describe("graph-tools", () => {
1021
1268
  const graphClient = {
1022
1269
  downloadToFile: vi.fn().mockResolvedValue({
1023
1270
  contentType: "image/jpeg",
1024
- contentLength: 2
1271
+ contentLength: 2,
1272
+ httpStatus: 200
1025
1273
  })
1026
1274
  };
1027
1275
  const server = createMockServer();
@@ -1039,6 +1287,15 @@ describe("graph-tools", () => {
1039
1287
  expect(result.isError).toBeUndefined();
1040
1288
  const payload = JSON.parse(result.content[0].text);
1041
1289
  expect(payload).toEqual({ path: outputPath, contentType: "image/jpeg", bytesWritten: 2 });
1290
+ expect(result._meta).toMatchObject({ http_status: 200 });
1291
+ expect(auditLogMock).toHaveBeenCalledWith(
1292
+ expect.objectContaining({
1293
+ event: "tool.call",
1294
+ tool: "download-bytes-to-file",
1295
+ status: "success",
1296
+ http_status: 200
1297
+ })
1298
+ );
1042
1299
  });
1043
1300
  it("rejects a relative outputPath", async () => {
1044
1301
  mockEndpoints.length = 0;
@@ -1178,7 +1435,8 @@ describe("graph-tools", () => {
1178
1435
  "@microsoft.graph.downloadUrl": downloadUrl
1179
1436
  })
1180
1437
  }
1181
- ]
1438
+ ],
1439
+ _meta: { http_status: 200 }
1182
1440
  })
1183
1441
  };
1184
1442
  const server = createMockServer();
@@ -1196,6 +1454,15 @@ describe("graph-tools", () => {
1196
1454
  expect(payload.name).toBe("report.pdf");
1197
1455
  expect(payload.size).toBe(12727);
1198
1456
  expect(payload.contentType).toBe("application/pdf");
1457
+ expect(result._meta).toMatchObject({ http_status: 200 });
1458
+ expect(auditLogMock).toHaveBeenCalledWith(
1459
+ expect.objectContaining({
1460
+ event: "tool.call",
1461
+ tool: "get-download-url",
1462
+ status: "success",
1463
+ http_status: 200
1464
+ })
1465
+ );
1199
1466
  });
1200
1467
  it("forces a JSON body on the metadata request so it works under --toon (#560)", async () => {
1201
1468
  mockEndpoints.length = 0;
@@ -1574,6 +1841,65 @@ describe("graph-tools", () => {
1574
1841
  expect(server.tools.has("list-mail-messages")).toBe(true);
1575
1842
  expect(server.tools.has("list-calendar-events")).toBe(false);
1576
1843
  });
1844
+ it("audits direct calls to Graph tools denied by allowed scopes", async () => {
1845
+ mockEndpoints.push({
1846
+ alias: "get-drive-item",
1847
+ method: "get",
1848
+ path: "/drives/:driveId/items/:driveItemId",
1849
+ description: "Get drive item",
1850
+ parameters: [
1851
+ { name: "driveId", type: "Path", schema: z.string() },
1852
+ { name: "driveItemId", type: "Path", schema: z.string() }
1853
+ ]
1854
+ });
1855
+ mockEndpointsJson = [
1856
+ {
1857
+ toolName: "get-drive-item",
1858
+ method: "get",
1859
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
1860
+ scopes: ["Files.Read"]
1861
+ }
1862
+ ];
1863
+ const server = createMockServer();
1864
+ const { registerGraphTools } = await loadModule();
1865
+ registerGraphTools(
1866
+ server,
1867
+ createMockGraphClient(),
1868
+ false,
1869
+ void 0,
1870
+ false,
1871
+ void 0,
1872
+ false,
1873
+ [],
1874
+ "Mail.Read"
1875
+ );
1876
+ const handler = server.server._requestHandlers.get("tools/call");
1877
+ await expect(
1878
+ handler?.(
1879
+ {
1880
+ method: "tools/call",
1881
+ params: {
1882
+ name: "get-drive-item",
1883
+ arguments: { driveId: "drive-1", driveItemId: "item-2" }
1884
+ }
1885
+ },
1886
+ {}
1887
+ )
1888
+ ).rejects.toThrow(/not found/);
1889
+ expect(auditLogMock).toHaveBeenCalledWith(
1890
+ expect.objectContaining({
1891
+ event: "tool.denied",
1892
+ tool: "get-drive-item",
1893
+ status: "denied",
1894
+ reason: "allowed_scopes",
1895
+ missing_scopes: ["Files.Read"],
1896
+ target_resource: {
1897
+ type: "drive_item",
1898
+ id: "/drives/drive-1/items/item-2"
1899
+ }
1900
+ })
1901
+ );
1902
+ });
1577
1903
  it("discovery hides Graph tools outside the allowed scopes", async () => {
1578
1904
  mockEndpoints.push(
1579
1905
  {
@@ -1623,6 +1949,117 @@ describe("graph-tools", () => {
1623
1949
  expect(found).toContain("list-mail-messages");
1624
1950
  expect(found).not.toContain("list-calendar-events");
1625
1951
  });
1952
+ it("audits execute-tool attempts denied by allowed scopes", async () => {
1953
+ mockEndpoints.push({
1954
+ alias: "get-drive-item",
1955
+ method: "get",
1956
+ path: "/drives/:driveId/items/:driveItemId",
1957
+ description: "Get drive item",
1958
+ parameters: [
1959
+ { name: "driveId", type: "Path", schema: z.string() },
1960
+ { name: "driveItemId", type: "Path", schema: z.string() }
1961
+ ]
1962
+ });
1963
+ mockEndpointsJson = [
1964
+ {
1965
+ toolName: "get-drive-item",
1966
+ method: "get",
1967
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
1968
+ scopes: ["Files.Read"]
1969
+ }
1970
+ ];
1971
+ const server = createMockServer();
1972
+ const { registerDiscoveryTools } = await loadModule();
1973
+ registerDiscoveryTools(
1974
+ server,
1975
+ {},
1976
+ false,
1977
+ false,
1978
+ void 0,
1979
+ false,
1980
+ [],
1981
+ void 0,
1982
+ "Mail.Read"
1983
+ );
1984
+ const result = await server.tools.get("execute-tool").handler({
1985
+ tool_name: "get-drive-item",
1986
+ parameters: { driveId: "drive-1", driveItemId: "item-2" }
1987
+ });
1988
+ expect(result.isError).toBe(true);
1989
+ expect(JSON.parse(result.content[0].text).error).toMatch(/not found/i);
1990
+ expect(auditLogMock).toHaveBeenCalledWith(
1991
+ expect.objectContaining({
1992
+ event: "tool.denied",
1993
+ tool: "get-drive-item",
1994
+ status: "denied",
1995
+ reason: "allowed_scopes",
1996
+ missing_scopes: ["Files.Read"],
1997
+ target_resource: {
1998
+ type: "drive_item",
1999
+ id: "/drives/drive-1/items/item-2"
2000
+ }
2001
+ })
2002
+ );
2003
+ });
2004
+ it("audits direct discovery-mode calls to Graph tools denied by allowed scopes", async () => {
2005
+ mockEndpoints.push({
2006
+ alias: "get-drive-item",
2007
+ method: "get",
2008
+ path: "/drives/:driveId/items/:driveItemId",
2009
+ description: "Get drive item",
2010
+ parameters: [
2011
+ { name: "driveId", type: "Path", schema: z.string() },
2012
+ { name: "driveItemId", type: "Path", schema: z.string() }
2013
+ ]
2014
+ });
2015
+ mockEndpointsJson = [
2016
+ {
2017
+ toolName: "get-drive-item",
2018
+ method: "get",
2019
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
2020
+ scopes: ["Files.Read"]
2021
+ }
2022
+ ];
2023
+ const server = createMockServer();
2024
+ const { registerDiscoveryTools } = await loadModule();
2025
+ registerDiscoveryTools(
2026
+ server,
2027
+ {},
2028
+ false,
2029
+ false,
2030
+ void 0,
2031
+ false,
2032
+ [],
2033
+ void 0,
2034
+ "Mail.Read"
2035
+ );
2036
+ const handler = server.server._requestHandlers.get("tools/call");
2037
+ await expect(
2038
+ handler?.(
2039
+ {
2040
+ method: "tools/call",
2041
+ params: {
2042
+ name: "get-drive-item",
2043
+ arguments: { driveId: "drive-1", driveItemId: "item-2" }
2044
+ }
2045
+ },
2046
+ {}
2047
+ )
2048
+ ).rejects.toThrow(/not found/);
2049
+ expect(auditLogMock).toHaveBeenCalledWith(
2050
+ expect.objectContaining({
2051
+ event: "tool.denied",
2052
+ tool: "get-drive-item",
2053
+ status: "denied",
2054
+ reason: "allowed_scopes",
2055
+ missing_scopes: ["Files.Read"],
2056
+ target_resource: {
2057
+ type: "drive_item",
2058
+ id: "/drives/drive-1/items/item-2"
2059
+ }
2060
+ })
2061
+ );
2062
+ });
1626
2063
  });
1627
2064
  describe("discovery mode: utility tools", () => {
1628
2065
  it('search-tools surfaces download-bytes for "download" queries', async () => {
@@ -1727,6 +2164,65 @@ describe("graph-tools", () => {
1727
2164
  expect(found).toContain("list-mail-messages");
1728
2165
  expect(found).not.toContain("list-calendar-events");
1729
2166
  });
2167
+ it("audits execute-tool attempts denied by the enabled-tools allow-list", async () => {
2168
+ mockEndpoints.push(
2169
+ {
2170
+ alias: "get-drive-item",
2171
+ method: "get",
2172
+ path: "/drives/:driveId/items/:driveItemId",
2173
+ description: "Get drive item",
2174
+ parameters: [
2175
+ { name: "driveId", type: "Path", schema: z.string() },
2176
+ { name: "driveItemId", type: "Path", schema: z.string() }
2177
+ ]
2178
+ },
2179
+ {
2180
+ alias: "list-mail-messages",
2181
+ method: "get",
2182
+ path: "/me/messages",
2183
+ description: "List mail",
2184
+ parameters: []
2185
+ }
2186
+ );
2187
+ mockEndpointsJson = [
2188
+ {
2189
+ toolName: "get-drive-item",
2190
+ method: "get",
2191
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}"
2192
+ },
2193
+ { toolName: "list-mail-messages", method: "get", pathPattern: "/me/messages" }
2194
+ ];
2195
+ const server = createMockServer();
2196
+ const { registerDiscoveryTools } = await loadModule();
2197
+ registerDiscoveryTools(
2198
+ server,
2199
+ {},
2200
+ false,
2201
+ false,
2202
+ void 0,
2203
+ false,
2204
+ [],
2205
+ "^list-mail-messages$"
2206
+ );
2207
+ const result = await server.tools.get("execute-tool").handler({
2208
+ tool_name: "get-drive-item",
2209
+ parameters: { driveId: "drive-1", driveItemId: "item-2" }
2210
+ });
2211
+ expect(result.isError).toBe(true);
2212
+ expect(JSON.parse(result.content[0].text).error).toMatch(/not found/i);
2213
+ expect(auditLogMock).toHaveBeenCalledWith(
2214
+ expect.objectContaining({
2215
+ event: "tool.denied",
2216
+ tool: "get-drive-item",
2217
+ status: "denied",
2218
+ reason: "tool_allowlist",
2219
+ target_resource: {
2220
+ type: "drive_item",
2221
+ id: "/drives/drive-1/items/item-2"
2222
+ }
2223
+ })
2224
+ );
2225
+ });
1730
2226
  it("utility tools obey the regex too", async () => {
1731
2227
  mockEndpoints.length = 0;
1732
2228
  mockEndpointsJson = [];