@softeria/ms-365-mcp-server 0.143.0 → 0.144.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.
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import GraphClient from "../graph-client.js";
3
+ import { fetchWithResilience } from "../lib/graph-resilience.js";
4
+ vi.mock("../logger.js", () => ({
5
+ default: {
6
+ info: vi.fn(),
7
+ warn: vi.fn(),
8
+ error: vi.fn(),
9
+ debug: vi.fn()
10
+ }
11
+ }));
12
+ vi.mock("../lib/graph-resilience.js", () => ({
13
+ fetchWithResilience: vi.fn(),
14
+ getSharedBreaker: vi.fn(() => ({})),
15
+ loadResilienceConfig: vi.fn(() => ({}))
16
+ }));
17
+ const fetchWithResilienceMock = vi.mocked(fetchWithResilience);
18
+ function createGraphClient() {
19
+ return new GraphClient(
20
+ {
21
+ getToken: vi.fn().mockResolvedValue("token")
22
+ },
23
+ {
24
+ clientId: "client-id",
25
+ tenantId: "tenant-id",
26
+ cloudType: "global"
27
+ }
28
+ );
29
+ }
30
+ describe("GraphClient audit metadata", () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+ it("adds HTTP status metadata to successful Graph responses", async () => {
35
+ fetchWithResilienceMock.mockResolvedValue(
36
+ new Response(JSON.stringify({ id: "user-1" }), {
37
+ status: 200,
38
+ headers: { "content-type": "application/json" }
39
+ })
40
+ );
41
+ const result = await createGraphClient().graphRequest("/me");
42
+ expect(result._meta).toMatchObject({ http_status: 200 });
43
+ expect(JSON.parse(result.content[0].text)).toEqual({ id: "user-1" });
44
+ });
45
+ it("preserves HTTP status metadata when response headers are requested", async () => {
46
+ fetchWithResilienceMock.mockResolvedValue(
47
+ new Response(JSON.stringify({ id: "task-1" }), {
48
+ status: 200,
49
+ headers: { "content-type": "application/json", etag: 'W/"etag-1"' }
50
+ })
51
+ );
52
+ const result = await createGraphClient().graphRequest("/me/planner/tasks/task-1", {
53
+ includeHeaders: true
54
+ });
55
+ expect(result._meta).toMatchObject({ http_status: 200 });
56
+ expect(JSON.parse(result.content[0].text)).toMatchObject({
57
+ id: "task-1",
58
+ _etag: 'W/"etag-1"'
59
+ });
60
+ });
61
+ it("adds HTTP status and Graph error code metadata to failed Graph responses", async () => {
62
+ fetchWithResilienceMock.mockResolvedValue(
63
+ new Response(
64
+ JSON.stringify({
65
+ error: {
66
+ code: "accessDenied",
67
+ message: "Access denied"
68
+ }
69
+ }),
70
+ {
71
+ status: 403,
72
+ statusText: "Forbidden",
73
+ headers: { "content-type": "application/json" }
74
+ }
75
+ )
76
+ );
77
+ const result = await createGraphClient().graphRequest("/me/drive");
78
+ expect(result.isError).toBe(true);
79
+ expect(result._meta).toMatchObject({
80
+ http_status: 403,
81
+ error_code: "accessDenied"
82
+ });
83
+ expect(JSON.parse(result.content[0].text).error).toContain(
84
+ "Microsoft Graph API error: 403 Forbidden"
85
+ );
86
+ });
87
+ it("adds aggregate subrequest metadata to successful Graph batch responses", async () => {
88
+ fetchWithResilienceMock.mockResolvedValue(
89
+ new Response(
90
+ JSON.stringify({
91
+ responses: [
92
+ { id: "1", status: 200, body: { id: "message-1" } },
93
+ {
94
+ id: "2",
95
+ status: 403,
96
+ body: { error: { code: "accessDenied", message: "Access denied" } }
97
+ },
98
+ {
99
+ id: "3",
100
+ status: 429,
101
+ body: { error: { code: "tooManyRequests", message: "Slow down" } }
102
+ },
103
+ {
104
+ id: "4",
105
+ status: 403,
106
+ body: { error: { code: "accessDenied", message: "Access denied" } }
107
+ }
108
+ ]
109
+ }),
110
+ {
111
+ status: 200,
112
+ headers: { "content-type": "application/json" }
113
+ }
114
+ )
115
+ );
116
+ const result = await createGraphClient().graphRequest("/$batch", {
117
+ method: "POST",
118
+ body: JSON.stringify({ requests: [] })
119
+ });
120
+ expect(result.isError).toBeUndefined();
121
+ expect(result._meta).toMatchObject({
122
+ http_status: 200,
123
+ graph_batch_subrequest_count: 4,
124
+ graph_batch_http_status_counts: { "200": 1, "403": 2, "429": 1 },
125
+ graph_batch_error_code_counts: { accessDenied: 2, tooManyRequests: 1 }
126
+ });
127
+ });
128
+ });
@@ -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,10 +98,6 @@ 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();
98
103
  return {
@@ -123,6 +128,187 @@ describe("graph-tools", () => {
123
128
  mockEndpointsJson = [];
124
129
  vi.clearAllMocks();
125
130
  });
131
+ describe("audit outcome metadata", () => {
132
+ it("includes HTTP status on successful Graph tool calls", async () => {
133
+ const endpoint = makeEndpoint();
134
+ const config = makeConfig();
135
+ mockEndpoints.push(endpoint);
136
+ mockEndpointsJson = [config];
137
+ const graphClient = createMockGraphClient([
138
+ {
139
+ content: [{ type: "text", text: JSON.stringify({ value: [] }) }],
140
+ _meta: { http_status: 200 }
141
+ }
142
+ ]);
143
+ const server = createMockServer();
144
+ const { registerGraphTools } = await loadModule();
145
+ registerGraphTools(
146
+ server,
147
+ graphClient
148
+ );
149
+ await server.tools.get("test-tool").handler({});
150
+ expect(auditLogMock).toHaveBeenCalledWith(
151
+ expect.objectContaining({
152
+ event: "tool.call",
153
+ tool: "test-tool",
154
+ status: "success",
155
+ http_status: 200
156
+ })
157
+ );
158
+ });
159
+ it("includes HTTP status and Graph error code on failed Graph tool calls", async () => {
160
+ const endpoint = makeEndpoint();
161
+ const config = makeConfig();
162
+ mockEndpoints.push(endpoint);
163
+ mockEndpointsJson = [config];
164
+ const graphClient = createMockGraphClient([
165
+ {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
170
+ }
171
+ ],
172
+ isError: true,
173
+ _meta: { http_status: 403, error_code: "accessDenied" }
174
+ }
175
+ ]);
176
+ const server = createMockServer();
177
+ const { registerGraphTools } = await loadModule();
178
+ registerGraphTools(server, graphClient);
179
+ await server.tools.get("test-tool").handler({});
180
+ expect(auditLogMock).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ event: "tool.call",
183
+ tool: "test-tool",
184
+ status: "error",
185
+ http_status: 403,
186
+ error_code: "accessDenied"
187
+ })
188
+ );
189
+ });
190
+ it("includes HTTP status on utility tool calls that reach Graph", async () => {
191
+ mockEndpoints.length = 0;
192
+ mockEndpointsJson = [];
193
+ const graphClient = {
194
+ graphRequest: vi.fn().mockResolvedValue({
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: JSON.stringify({
199
+ contentType: "image/jpeg",
200
+ encoding: "base64",
201
+ contentBytes: "aGk="
202
+ })
203
+ }
204
+ ],
205
+ _meta: { http_status: 200 }
206
+ })
207
+ };
208
+ const server = createMockServer();
209
+ const { registerGraphTools } = await loadModule();
210
+ registerGraphTools(server, graphClient);
211
+ await server.tools.get("download-bytes").handler({ target: "/me/photo/$value" });
212
+ expect(auditLogMock).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ event: "tool.call",
215
+ tool: "download-bytes",
216
+ status: "success",
217
+ http_method: "GET",
218
+ http_status: 200
219
+ })
220
+ );
221
+ });
222
+ it("includes HTTP status and Graph error code on failed utility Graph calls", async () => {
223
+ mockEndpoints.length = 0;
224
+ mockEndpointsJson = [];
225
+ const graphClient = {
226
+ graphRequest: vi.fn().mockResolvedValue({
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: JSON.stringify({ error: "Microsoft Graph API error: 403 Forbidden" })
231
+ }
232
+ ],
233
+ isError: true,
234
+ _meta: { http_status: 403, error_code: "accessDenied" }
235
+ })
236
+ };
237
+ const server = createMockServer();
238
+ const { registerGraphTools } = await loadModule();
239
+ registerGraphTools(server, graphClient);
240
+ await server.tools.get("download-bytes").handler({ target: "/me/photo/$value" });
241
+ expect(auditLogMock).toHaveBeenCalledWith(
242
+ expect.objectContaining({
243
+ event: "tool.call",
244
+ tool: "download-bytes",
245
+ status: "error",
246
+ http_status: 403,
247
+ error_code: "accessDenied"
248
+ })
249
+ );
250
+ });
251
+ it("copies Graph batch outcome metadata into audit events", async () => {
252
+ const endpoint = makeEndpoint({
253
+ method: "post",
254
+ path: "/$batch",
255
+ alias: "graph-batch",
256
+ parameters: [{ name: "body", type: "Body", schema: z.object({}).passthrough() }]
257
+ });
258
+ const config = makeConfig({
259
+ pathPattern: "/$batch",
260
+ method: "post",
261
+ toolName: "graph-batch"
262
+ });
263
+ mockEndpoints.push(endpoint);
264
+ mockEndpointsJson = [config];
265
+ const graphClient = createMockGraphClient([
266
+ {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: JSON.stringify({
271
+ responses: [
272
+ { id: "1", status: 200, body: { id: "message-1" } },
273
+ {
274
+ id: "2",
275
+ status: 403,
276
+ body: { error: { code: "accessDenied", message: "Access denied" } }
277
+ }
278
+ ]
279
+ })
280
+ }
281
+ ],
282
+ _meta: {
283
+ http_status: 200,
284
+ graph_batch_subrequest_count: 2,
285
+ graph_batch_http_status_counts: { "200": 1, "403": 1 },
286
+ graph_batch_error_code_counts: { accessDenied: 1 }
287
+ }
288
+ }
289
+ ]);
290
+ const server = createMockServer();
291
+ const { registerGraphTools } = await loadModule();
292
+ registerGraphTools(
293
+ server,
294
+ graphClient
295
+ );
296
+ await server.tools.get("graph-batch").handler({
297
+ body: { requests: [{ id: "1", method: "GET", url: "/me" }] }
298
+ });
299
+ expect(auditLogMock).toHaveBeenCalledWith(
300
+ expect.objectContaining({
301
+ event: "tool.call",
302
+ tool: "graph-batch",
303
+ status: "success",
304
+ http_status: 200,
305
+ graph_batch_subrequest_count: 2,
306
+ graph_batch_http_status_counts: { "200": 1, "403": 1 },
307
+ graph_batch_error_code_counts: { accessDenied: 1 }
308
+ })
309
+ );
310
+ });
311
+ });
126
312
  describe("$count advanced query mode", () => {
127
313
  it("should set ConsistencyLevel: eventual header when $count=true", async () => {
128
314
  const endpoint = makeEndpoint();
@@ -166,12 +352,11 @@ describe("graph-tools", () => {
166
352
  const server = createMockServer();
167
353
  const { registerGraphTools } = await loadModule();
168
354
  registerGraphTools(server, graphClient);
169
- const auditSpy = await spyOnAuditLogger();
170
355
  await server.tools.get("get-drive-item").handler({
171
356
  driveId: "drive-1",
172
357
  driveItemId: "item-2"
173
358
  });
174
- expect(auditSpy).toHaveBeenCalledWith(
359
+ expect(auditLogMock).toHaveBeenCalledWith(
175
360
  expect.objectContaining({
176
361
  event: "tool.call",
177
362
  tool: "get-drive-item",
@@ -182,7 +367,6 @@ describe("graph-tools", () => {
182
367
  }
183
368
  })
184
369
  );
185
- auditSpy.mockRestore();
186
370
  });
187
371
  it("adds target_resource to failed generated Graph tool audit events", async () => {
188
372
  const endpoint = makeEndpoint({
@@ -210,13 +394,12 @@ describe("graph-tools", () => {
210
394
  server,
211
395
  graphClient
212
396
  );
213
- const auditSpy = await spyOnAuditLogger();
214
397
  const result = await server.tools.get("get-drive-item").handler({
215
398
  driveId: "drive-1",
216
399
  driveItemId: "item-2"
217
400
  });
218
401
  expect(result.isError).toBe(true);
219
- expect(auditSpy).toHaveBeenCalledWith(
402
+ expect(auditLogMock).toHaveBeenCalledWith(
220
403
  expect.objectContaining({
221
404
  event: "tool.call",
222
405
  tool: "get-drive-item",
@@ -228,7 +411,6 @@ describe("graph-tools", () => {
228
411
  }
229
412
  })
230
413
  );
231
- auditSpy.mockRestore();
232
414
  });
233
415
  it("derives target_resource from generic ID path parameters", async () => {
234
416
  const endpoint = makeEndpoint({
@@ -248,11 +430,10 @@ describe("graph-tools", () => {
248
430
  const server = createMockServer();
249
431
  const { registerGraphTools } = await loadModule();
250
432
  registerGraphTools(server, graphClient);
251
- const auditSpy = await spyOnAuditLogger();
252
433
  await server.tools.get("get-mail-message").handler({
253
434
  messageId: "message-1"
254
435
  });
255
- expect(auditSpy).toHaveBeenCalledWith(
436
+ expect(auditLogMock).toHaveBeenCalledWith(
256
437
  expect.objectContaining({
257
438
  event: "tool.call",
258
439
  tool: "get-mail-message",
@@ -263,7 +444,6 @@ describe("graph-tools", () => {
263
444
  }
264
445
  })
265
446
  );
266
- auditSpy.mockRestore();
267
447
  });
268
448
  it("omits target_resource when an ID path parameter is missing", async () => {
269
449
  const endpoint = makeEndpoint({
@@ -287,18 +467,16 @@ describe("graph-tools", () => {
287
467
  const server = createMockServer();
288
468
  const { registerGraphTools } = await loadModule();
289
469
  registerGraphTools(server, graphClient);
290
- const auditSpy = await spyOnAuditLogger();
291
470
  await server.tools.get("get-drive-item").handler({
292
471
  driveId: "drive-1"
293
472
  });
294
- const [payload] = auditSpy.mock.calls[0];
473
+ const [payload] = auditLogMock.mock.calls[0];
295
474
  expect(payload).toMatchObject({
296
475
  event: "tool.call",
297
476
  tool: "get-drive-item",
298
477
  status: "success"
299
478
  });
300
479
  expect(payload).not.toHaveProperty("target_resource");
301
- auditSpy.mockRestore();
302
480
  });
303
481
  it("omits SharePoint path parameters from target_resource", async () => {
304
482
  const endpoint = makeEndpoint({
@@ -322,12 +500,11 @@ describe("graph-tools", () => {
322
500
  const server = createMockServer();
323
501
  const { registerGraphTools } = await loadModule();
324
502
  registerGraphTools(server, graphClient);
325
- const auditSpy = await spyOnAuditLogger();
326
503
  await server.tools.get("get-sharepoint-site-by-path").handler({
327
504
  siteId: "contoso.sharepoint.com",
328
505
  path: "/sites/Finance"
329
506
  });
330
- expect(auditSpy).toHaveBeenCalledWith(
507
+ expect(auditLogMock).toHaveBeenCalledWith(
331
508
  expect.objectContaining({
332
509
  event: "tool.call",
333
510
  tool: "get-sharepoint-site-by-path",
@@ -338,9 +515,8 @@ describe("graph-tools", () => {
338
515
  }
339
516
  })
340
517
  );
341
- const [payload] = auditSpy.mock.calls[0];
518
+ const [payload] = auditLogMock.mock.calls[0];
342
519
  expect(JSON.stringify(payload)).not.toContain("Finance");
343
- auditSpy.mockRestore();
344
520
  });
345
521
  it("omits target_resource for generated broad list/search audit events", async () => {
346
522
  const endpoint = makeEndpoint({
@@ -359,16 +535,14 @@ describe("graph-tools", () => {
359
535
  const server = createMockServer();
360
536
  const { registerGraphTools } = await loadModule();
361
537
  registerGraphTools(server, graphClient);
362
- const auditSpy = await spyOnAuditLogger();
363
538
  await server.tools.get("list-mail-messages").handler({ search: "budget" });
364
- const [payload] = auditSpy.mock.calls[0];
539
+ const [payload] = auditLogMock.mock.calls[0];
365
540
  expect(payload).toMatchObject({
366
541
  event: "tool.call",
367
542
  tool: "list-mail-messages",
368
543
  status: "success"
369
544
  });
370
545
  expect(payload).not.toHaveProperty("target_resource");
371
- auditSpy.mockRestore();
372
546
  });
373
547
  });
374
548
  describe("fetchAllPages pagination", () => {
@@ -411,6 +585,55 @@ describe("graph-tools", () => {
411
585
  expect(parsed.value.map((v) => v.id)).toEqual(["1", "2", "3"]);
412
586
  expect(parsed["@odata.nextLink"]).toBeUndefined();
413
587
  });
588
+ it("returns and audits a later-page Graph error instead of partial success", async () => {
589
+ const endpoint = makeEndpoint();
590
+ const config = makeConfig();
591
+ mockEndpoints.push(endpoint);
592
+ mockEndpointsJson = [config];
593
+ const graphClient = createMockGraphClient([
594
+ {
595
+ content: [
596
+ {
597
+ type: "text",
598
+ text: JSON.stringify({
599
+ value: [{ id: "1" }],
600
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=1"
601
+ })
602
+ }
603
+ ],
604
+ _meta: { http_status: 200 }
605
+ },
606
+ {
607
+ content: [
608
+ {
609
+ type: "text",
610
+ text: JSON.stringify({ error: "Microsoft Graph API error: 429 Too Many Requests" })
611
+ }
612
+ ],
613
+ isError: true,
614
+ _meta: { http_status: 429, error_code: "tooManyRequests" }
615
+ }
616
+ ]);
617
+ const server = createMockServer();
618
+ const { registerGraphTools } = await loadModule();
619
+ registerGraphTools(server, graphClient);
620
+ const result = await server.tools.get("test-tool").handler({ fetchAllPages: true });
621
+ expect(result.isError).toBe(true);
622
+ expect(JSON.parse(result.content[0].text).error).toContain("429 Too Many Requests");
623
+ expect(graphClient.graphRequest).toHaveBeenCalledTimes(2);
624
+ expect(loggerMock.error).not.toHaveBeenCalledWith(
625
+ expect.stringContaining("Error during pagination")
626
+ );
627
+ expect(auditLogMock).toHaveBeenCalledWith(
628
+ expect.objectContaining({
629
+ event: "tool.call",
630
+ tool: "test-tool",
631
+ status: "error",
632
+ http_status: 429,
633
+ error_code: "tooManyRequests"
634
+ })
635
+ );
636
+ });
414
637
  it("merges all pages under --toon and encodes the combined result once (#560)", async () => {
415
638
  const endpoint = makeEndpoint();
416
639
  const config = makeConfig();
@@ -1021,7 +1244,8 @@ describe("graph-tools", () => {
1021
1244
  const graphClient = {
1022
1245
  downloadToFile: vi.fn().mockResolvedValue({
1023
1246
  contentType: "image/jpeg",
1024
- contentLength: 2
1247
+ contentLength: 2,
1248
+ httpStatus: 200
1025
1249
  })
1026
1250
  };
1027
1251
  const server = createMockServer();
@@ -1039,6 +1263,15 @@ describe("graph-tools", () => {
1039
1263
  expect(result.isError).toBeUndefined();
1040
1264
  const payload = JSON.parse(result.content[0].text);
1041
1265
  expect(payload).toEqual({ path: outputPath, contentType: "image/jpeg", bytesWritten: 2 });
1266
+ expect(result._meta).toMatchObject({ http_status: 200 });
1267
+ expect(auditLogMock).toHaveBeenCalledWith(
1268
+ expect.objectContaining({
1269
+ event: "tool.call",
1270
+ tool: "download-bytes-to-file",
1271
+ status: "success",
1272
+ http_status: 200
1273
+ })
1274
+ );
1042
1275
  });
1043
1276
  it("rejects a relative outputPath", async () => {
1044
1277
  mockEndpoints.length = 0;
@@ -1178,7 +1411,8 @@ describe("graph-tools", () => {
1178
1411
  "@microsoft.graph.downloadUrl": downloadUrl
1179
1412
  })
1180
1413
  }
1181
- ]
1414
+ ],
1415
+ _meta: { http_status: 200 }
1182
1416
  })
1183
1417
  };
1184
1418
  const server = createMockServer();
@@ -1196,6 +1430,15 @@ describe("graph-tools", () => {
1196
1430
  expect(payload.name).toBe("report.pdf");
1197
1431
  expect(payload.size).toBe(12727);
1198
1432
  expect(payload.contentType).toBe("application/pdf");
1433
+ expect(result._meta).toMatchObject({ http_status: 200 });
1434
+ expect(auditLogMock).toHaveBeenCalledWith(
1435
+ expect.objectContaining({
1436
+ event: "tool.call",
1437
+ tool: "get-download-url",
1438
+ status: "success",
1439
+ http_status: 200
1440
+ })
1441
+ );
1199
1442
  });
1200
1443
  it("forces a JSON body on the metadata request so it works under --toon (#560)", async () => {
1201
1444
  mockEndpoints.length = 0;
@@ -34,7 +34,6 @@ function registerAuthTools(server, authManager) {
34
34
  type: "text",
35
35
  text: JSON.stringify({
36
36
  status: "Login successful",
37
- message: "Browser authentication completed successfully.",
38
37
  ...loginResult
39
38
  })
40
39
  }
@@ -31,6 +31,62 @@ function isBinaryContentType(contentType) {
31
31
  }
32
32
  return false;
33
33
  }
34
+ class GraphApiError extends Error {
35
+ constructor(message, httpStatus, graphErrorCode) {
36
+ super(message);
37
+ this.name = "GraphApiError";
38
+ this.httpStatus = httpStatus;
39
+ this.graphErrorCode = graphErrorCode;
40
+ }
41
+ }
42
+ function extractGraphErrorCode(errorText) {
43
+ try {
44
+ const body = JSON.parse(errorText);
45
+ const code = body.error?.code ?? body.code;
46
+ return typeof code === "string" ? code : void 0;
47
+ } catch {
48
+ return void 0;
49
+ }
50
+ }
51
+ function definedMetadata(metadata) {
52
+ return Object.fromEntries(
53
+ Object.entries(metadata).filter(([, value]) => value !== void 0)
54
+ );
55
+ }
56
+ function isRecord(value) {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+ function incrementCount(counts, key) {
60
+ counts[key] = (counts[key] ?? 0) + 1;
61
+ }
62
+ function extractGraphErrorCodeFromBody(body) {
63
+ if (!isRecord(body)) return void 0;
64
+ const error = body.error;
65
+ const code = isRecord(error) ? error.code : body.code;
66
+ return typeof code === "string" ? code : void 0;
67
+ }
68
+ function extractBatchMetadata(data) {
69
+ if (!isRecord(data) || !Array.isArray(data.responses)) return {};
70
+ const httpStatusCounts = {};
71
+ const errorCodeCounts = {};
72
+ for (const item of data.responses) {
73
+ if (!isRecord(item)) continue;
74
+ const status = item.status;
75
+ if (typeof status !== "number" || !Number.isInteger(status)) continue;
76
+ incrementCount(httpStatusCounts, String(status));
77
+ if (status >= 400) {
78
+ const errorCode = extractGraphErrorCodeFromBody(item.body);
79
+ if (errorCode) {
80
+ incrementCount(errorCodeCounts, errorCode);
81
+ }
82
+ }
83
+ }
84
+ return {
85
+ graph_batch_subrequest_count: data.responses.length,
86
+ ...Object.keys(httpStatusCounts).length > 0 ? { graph_batch_http_status_counts: httpStatusCounts } : {},
87
+ ...Object.keys(errorCodeCounts).length > 0 ? { graph_batch_error_code_counts: errorCodeCounts } : {}
88
+ };
89
+ }
34
90
  class GraphClient {
35
91
  constructor(authManager, secrets, outputFormat = "json") {
36
92
  this.outputFormat = "json";
@@ -39,6 +95,9 @@ class GraphClient {
39
95
  this.outputFormat = outputFormat;
40
96
  }
41
97
  async makeRequest(endpoint, options = {}) {
98
+ return (await this.makeRequestWithMetadata(endpoint, options)).data;
99
+ }
100
+ async makeRequestWithMetadata(endpoint, options = {}) {
42
101
  const contextTokens = getRequestTokens();
43
102
  const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
44
103
  if (!accessToken) {
@@ -47,23 +106,14 @@ class GraphClient {
47
106
  try {
48
107
  const response = await this.performRequest(endpoint, accessToken, options);
49
108
  if (response.status === 403) {
50
- const errorText = await response.text();
51
- if (errorText.includes("scope") || errorText.includes("permission")) {
52
- throw new Error(
53
- `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
54
- );
55
- }
56
- throw new Error(
57
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
58
- );
109
+ throw await this.createGraphApiError(response);
59
110
  }
60
111
  if (!response.ok) {
61
- throw new Error(
62
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
63
- );
112
+ throw await this.createGraphApiError(response);
64
113
  }
65
114
  const contentTypeHeader = response.headers?.get?.("content-type") || "";
66
115
  const isBinaryResponse = isBinaryContentType(contentTypeHeader);
116
+ let metadata = { http_status: response.status };
67
117
  let result;
68
118
  if (isBinaryResponse) {
69
119
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -88,16 +138,22 @@ class GraphClient {
88
138
  }
89
139
  }
90
140
  }
141
+ if (endpoint === "/$batch") {
142
+ metadata = { ...metadata, ...extractBatchMetadata(result) };
143
+ }
91
144
  if (options.includeHeaders) {
92
145
  const etag = response.headers.get("ETag") || response.headers.get("etag");
93
146
  if (result && typeof result === "object" && !Array.isArray(result)) {
94
147
  return {
95
- ...result,
96
- _etag: etag || "no-etag-found"
148
+ data: {
149
+ ...result,
150
+ _etag: etag || "no-etag-found"
151
+ },
152
+ metadata
97
153
  };
98
154
  }
99
155
  }
100
- return result;
156
+ return { data: result, metadata };
101
157
  } catch (error) {
102
158
  logger.error("Microsoft Graph API request failed:", error);
103
159
  throw error;
@@ -121,20 +177,10 @@ class GraphClient {
121
177
  }
122
178
  const response = await this.performRequest(endpoint, accessToken, options);
123
179
  if (response.status === 403) {
124
- const errorText = await response.text();
125
- if (errorText.includes("scope") || errorText.includes("permission")) {
126
- throw new Error(
127
- `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
128
- );
129
- }
130
- throw new Error(
131
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
132
- );
180
+ throw await this.createGraphApiError(response);
133
181
  }
134
182
  if (!response.ok) {
135
- throw new Error(
136
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
137
- );
183
+ throw await this.createGraphApiError(response);
138
184
  }
139
185
  if (!response.body) {
140
186
  throw new Error("Microsoft Graph returned an empty response body");
@@ -150,7 +196,8 @@ class GraphClient {
150
196
  }
151
197
  return {
152
198
  contentType: response.headers.get("content-type") || "application/octet-stream",
153
- contentLength
199
+ contentLength,
200
+ httpStatus: response.status
154
201
  };
155
202
  } catch (error) {
156
203
  logger.error("Microsoft Graph file download failed:", error);
@@ -162,6 +209,23 @@ class GraphClient {
162
209
  }
163
210
  }
164
211
  }
212
+ async createGraphApiError(response) {
213
+ const errorText = await response.text();
214
+ const graphErrorCode = extractGraphErrorCode(errorText);
215
+ const isPermissionOrScopeError = errorText.includes("scope") || errorText.includes("permission");
216
+ if (response.status === 403 && isPermissionOrScopeError) {
217
+ return new GraphApiError(
218
+ `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`,
219
+ response.status,
220
+ graphErrorCode
221
+ );
222
+ }
223
+ return new GraphApiError(
224
+ `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`,
225
+ response.status,
226
+ graphErrorCode
227
+ );
228
+ }
165
229
  async performRequest(endpoint, accessToken, options) {
166
230
  const cloudEndpoints = getCloudEndpoints(this.secrets.cloudType);
167
231
  const apiVersion = options.apiVersion || "v1.0";
@@ -211,22 +275,36 @@ class GraphClient {
211
275
  logger.info(
212
276
  `Calling ${endpoint} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
213
277
  );
214
- const result = await this.makeRequest(endpoint, options);
278
+ const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
215
279
  const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
216
- return this.formatJsonResponse(
217
- result,
218
- options.rawResponse,
219
- options.excludeResponse,
220
- outputFormat
280
+ return this.withResponseMetadata(
281
+ this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
282
+ metadata
221
283
  );
222
284
  } catch (error) {
223
285
  logger.error(`Error in Graph API request: ${error}`);
286
+ const metadata = error instanceof GraphApiError ? definedMetadata({
287
+ http_status: error.httpStatus,
288
+ error_code: error.graphErrorCode
289
+ }) : void 0;
224
290
  return {
225
291
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
226
- isError: true
292
+ isError: true,
293
+ ...metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
227
294
  };
228
295
  }
229
296
  }
297
+ withResponseMetadata(response, metadata) {
298
+ const cleanMetadata = definedMetadata(metadata);
299
+ if (Object.keys(cleanMetadata).length === 0) return response;
300
+ return {
301
+ ...response,
302
+ _meta: {
303
+ ...response._meta,
304
+ ...cleanMetadata
305
+ }
306
+ };
307
+ }
230
308
  formatJsonResponse(data, rawResponse = false, excludeResponse = false, outputFormat = this.outputFormat) {
231
309
  if (excludeResponse) {
232
310
  return {
@@ -302,6 +380,7 @@ class GraphClient {
302
380
  }
303
381
  var graph_client_default = GraphClient;
304
382
  export {
383
+ GraphApiError,
305
384
  graph_client_default as default,
306
385
  isBinaryContentType
307
386
  };
@@ -76,6 +76,86 @@ const DEFAULT_MAX_ITEMS = 1e4;
76
76
  function isConfirmGateEnabled() {
77
77
  return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
78
78
  }
79
+ function auditHttpStatus(value) {
80
+ if (typeof value === "number" && Number.isInteger(value)) return value;
81
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
82
+ return void 0;
83
+ }
84
+ function auditErrorCode(value) {
85
+ return typeof value === "string" || typeof value === "number" ? value : void 0;
86
+ }
87
+ function auditNonNegativeInteger(value) {
88
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
89
+ }
90
+ function auditStringNumberMap(value) {
91
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
92
+ const entries = Object.entries(value).filter(
93
+ (entry) => typeof entry[1] === "number" && Number.isInteger(entry[1]) && entry[1] >= 0
94
+ );
95
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
96
+ }
97
+ function graphResponseAuditFields(response) {
98
+ const httpStatus = auditHttpStatus(response._meta?.http_status);
99
+ const errorCode = response.isError ? auditErrorCode(response._meta?.error_code) : void 0;
100
+ const graphBatchSubrequestCount = auditNonNegativeInteger(
101
+ response._meta?.graph_batch_subrequest_count
102
+ );
103
+ const graphBatchHttpStatusCounts = auditStringNumberMap(
104
+ response._meta?.graph_batch_http_status_counts
105
+ );
106
+ const graphBatchErrorCodeCounts = auditStringNumberMap(
107
+ response._meta?.graph_batch_error_code_counts
108
+ );
109
+ return {
110
+ ...httpStatus !== void 0 ? { http_status: httpStatus } : {},
111
+ ...errorCode !== void 0 ? { error_code: errorCode } : {},
112
+ ...graphBatchSubrequestCount !== void 0 ? { graph_batch_subrequest_count: graphBatchSubrequestCount } : {},
113
+ ...graphBatchHttpStatusCounts !== void 0 ? { graph_batch_http_status_counts: graphBatchHttpStatusCounts } : {},
114
+ ...graphBatchErrorCodeCounts !== void 0 ? { graph_batch_error_code_counts: graphBatchErrorCodeCounts } : {}
115
+ };
116
+ }
117
+ function thrownErrorAuditFields(error) {
118
+ const err = error;
119
+ const httpStatus = auditHttpStatus(err?.httpStatus ?? err?.status);
120
+ const errorCode = auditErrorCode(err?.graphErrorCode ?? err?.code ?? err?.status);
121
+ return {
122
+ ...httpStatus !== void 0 ? { http_status: httpStatus } : {},
123
+ ...errorCode !== void 0 ? { error_code: errorCode } : {}
124
+ };
125
+ }
126
+ async function executeUtilityTool(utility, ctx, params) {
127
+ const requestId = randomUUID();
128
+ const startTime = Date.now();
129
+ const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
130
+ try {
131
+ const response = await utility.execute(params, ctx);
132
+ auditLog({
133
+ event: "tool.call",
134
+ request_id: requestId,
135
+ user_principal_name: upn,
136
+ tool: utility.name,
137
+ http_method: utility.method.toUpperCase(),
138
+ status: response.isError ? "error" : "success",
139
+ duration_ms: Date.now() - startTime,
140
+ ...graphResponseAuditFields(response)
141
+ });
142
+ return response;
143
+ } catch (error) {
144
+ const err = error;
145
+ auditLog({
146
+ event: "tool.call",
147
+ request_id: requestId,
148
+ user_principal_name: upn,
149
+ tool: utility.name,
150
+ http_method: utility.method.toUpperCase(),
151
+ status: "error",
152
+ duration_ms: Date.now() - startTime,
153
+ error_type: err?.name || "Error",
154
+ ...thrownErrorAuditFields(error)
155
+ });
156
+ throw error;
157
+ }
158
+ }
79
159
  function formatDisabledToolsForLog(disabledTools) {
80
160
  const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
81
161
  const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
@@ -316,12 +396,15 @@ const UTILITY_TOOLS = [
316
396
  bytesWritten: result.contentLength
317
397
  })
318
398
  }
319
- ]
399
+ ],
400
+ ...result.httpStatus !== void 0 ? { _meta: { http_status: result.httpStatus } } : {}
320
401
  };
321
402
  } catch (error) {
403
+ const metadata = thrownErrorAuditFields(error);
322
404
  return {
323
405
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
324
- isError: true
406
+ isError: true,
407
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
325
408
  };
326
409
  }
327
410
  }
@@ -496,7 +579,8 @@ const UTILITY_TOOLS = [
496
579
  })
497
580
  }
498
581
  ],
499
- isError: true
582
+ isError: true,
583
+ _meta: response._meta
500
584
  };
501
585
  }
502
586
  const file = item?.file;
@@ -511,12 +595,15 @@ const UTILITY_TOOLS = [
511
595
  contentType: file?.mimeType
512
596
  })
513
597
  }
514
- ]
598
+ ],
599
+ _meta: response._meta
515
600
  };
516
601
  } catch (error) {
602
+ const metadata = thrownErrorAuditFields(error);
517
603
  return {
518
604
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
519
- isError: true
605
+ isError: true,
606
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
520
607
  };
521
608
  }
522
609
  }
@@ -532,7 +619,7 @@ function registerUtilityToolWithMcp(server, utility, ctx) {
532
619
  readOnlyHint: utility.readOnlyHint ?? true,
533
620
  openWorldHint: utility.openWorldHint ?? true
534
621
  },
535
- async (params) => utility.execute(params, ctx)
622
+ async (params) => executeUtilityTool(utility, ctx, params)
536
623
  );
537
624
  }
538
625
  function namesNestedBodyType(value) {
@@ -886,6 +973,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
886
973
  const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
887
974
  const nextOptions = { ...options };
888
975
  const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
976
+ if (nextResponse?.isError) {
977
+ response = nextResponse;
978
+ combinedResponse = void 0;
979
+ break;
980
+ }
889
981
  if (nextResponse?.content?.[0]?.text) {
890
982
  const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
891
983
  if (Array.isArray(nextJsonResponse.value)) {
@@ -900,25 +992,27 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
900
992
  break;
901
993
  }
902
994
  }
903
- if (pageCount >= maxPages) {
904
- logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
905
- }
906
- if (allItems.length >= maxItems) {
907
- logger.warn(
908
- `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
995
+ if (combinedResponse !== void 0) {
996
+ if (pageCount >= maxPages) {
997
+ logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
998
+ }
999
+ if (allItems.length >= maxItems) {
1000
+ logger.warn(
1001
+ `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
1002
+ );
1003
+ }
1004
+ combinedResponse.value = allItems;
1005
+ if (combinedResponse["@odata.count"]) {
1006
+ combinedResponse["@odata.count"] = allItems.length;
1007
+ }
1008
+ delete combinedResponse["@odata.nextLink"];
1009
+ if (deltaLink) {
1010
+ combinedResponse["@odata.deltaLink"] = deltaLink;
1011
+ }
1012
+ logger.info(
1013
+ `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
909
1014
  );
910
1015
  }
911
- combinedResponse.value = allItems;
912
- if (combinedResponse["@odata.count"]) {
913
- combinedResponse["@odata.count"] = allItems.length;
914
- }
915
- delete combinedResponse["@odata.nextLink"];
916
- if (deltaLink) {
917
- combinedResponse["@odata.deltaLink"] = deltaLink;
918
- }
919
- logger.info(
920
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
921
- );
922
1016
  }
923
1017
  } catch (e) {
924
1018
  logger.error(`Error during pagination: ${e}`);
@@ -953,7 +1047,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
953
1047
  http_method: httpMethod,
954
1048
  status: response.isError ? "error" : "success",
955
1049
  duration_ms: Date.now() - startTime,
956
- ...targetResource ? { target_resource: targetResource } : {}
1050
+ ...targetResource ? { target_resource: targetResource } : {},
1051
+ ...graphResponseAuditFields(response)
957
1052
  });
958
1053
  return {
959
1054
  content,
@@ -973,7 +1068,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
973
1068
  duration_ms: Date.now() - startTime,
974
1069
  ...targetResource ? { target_resource: targetResource } : {},
975
1070
  error_type: err?.name || "Error",
976
- error_code: err?.status ?? err?.code
1071
+ ...thrownErrorAuditFields(error)
977
1072
  });
978
1073
  return {
979
1074
  content: [
@@ -1450,7 +1545,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1450
1545
  }
1451
1546
  const utility = utilityByName.get(tool_name);
1452
1547
  if (utility) {
1453
- return utility.execute(parameters, utilityCtx);
1548
+ return executeUtilityTool(utility, utilityCtx, parameters);
1454
1549
  }
1455
1550
  return {
1456
1551
  content: [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.143.0",
4
+ "version": "0.144.0",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -19,7 +19,8 @@
19
19
  "format:check": "prettier --check \"**/*.{ts,mts,js,mjs,json,md}\"",
20
20
  "lint": "eslint .",
21
21
  "lint:fix": "eslint . --fix",
22
- "verify": "npm run generate && npm run lint && npm run format:check && npm run build && npm run test",
22
+ "typecheck": "tsc --noEmit",
23
+ "verify": "npm run generate && npm run lint && npm run format:check && npm run typecheck && npm run build && npm run test",
23
24
  "inspector": "npx @modelcontextprotocol/inspector tsx src/index.ts"
24
25
  },
25
26
  "keywords": [