@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.
- package/dist/__tests__/graph-client.test.js +128 -0
- package/dist/__tests__/graph-tools.test.js +527 -31
- package/dist/auth-tools.js +0 -1
- package/dist/graph-client.js +114 -35
- package/dist/graph-tools.js +223 -26
- package/docs/deployment.md +1 -1
- package/package.json +3 -2
|
@@ -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(
|
|
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(
|
|
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(
|
|
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] =
|
|
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(
|
|
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] =
|
|
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] =
|
|
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 = [];
|