@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.
- package/dist/__tests__/graph-client.test.js +128 -0
- package/dist/__tests__/graph-tools.test.js +274 -31
- package/dist/auth-tools.js +0 -1
- package/dist/graph-client.js +114 -35
- package/dist/graph-tools.js +121 -26
- package/package.json +3 -2
|
@@ -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(
|
|
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(
|
|
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(
|
|
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] =
|
|
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(
|
|
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] =
|
|
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] =
|
|
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;
|
package/dist/auth-tools.js
CHANGED
package/dist/graph-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
278
|
+
const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
|
|
215
279
|
const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
|
|
216
|
-
return this.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
};
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
|
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 (
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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": [
|