@softeria/ms-365-mcp-server 0.145.2 → 0.146.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/graph-tools.test.js +333 -0
- package/dist/graph-tools.js +93 -4
- package/docs/deployment.md +1 -1
- package/package.json +1 -1
|
@@ -333,6 +333,339 @@ describe("graph-tools", () => {
|
|
|
333
333
|
);
|
|
334
334
|
});
|
|
335
335
|
});
|
|
336
|
+
describe("audit recipient metadata", () => {
|
|
337
|
+
const draftEndpoint = () => {
|
|
338
|
+
const endpoint = makeEndpoint({
|
|
339
|
+
method: "post",
|
|
340
|
+
path: "/me/messages",
|
|
341
|
+
alias: "create-draft-email",
|
|
342
|
+
parameters: [{ name: "body", type: "Body", schema: z.object({}).passthrough() }]
|
|
343
|
+
});
|
|
344
|
+
const config = makeConfig({
|
|
345
|
+
pathPattern: "/me/messages",
|
|
346
|
+
method: "post",
|
|
347
|
+
toolName: "create-draft-email"
|
|
348
|
+
});
|
|
349
|
+
mockEndpoints.push(endpoint);
|
|
350
|
+
mockEndpointsJson = [config];
|
|
351
|
+
};
|
|
352
|
+
const runDraft = async (body) => {
|
|
353
|
+
const graphClient = createMockGraphClient([
|
|
354
|
+
{
|
|
355
|
+
content: [{ type: "text", text: JSON.stringify({ id: "draft-1" }) }],
|
|
356
|
+
_meta: { http_status: 201 }
|
|
357
|
+
}
|
|
358
|
+
]);
|
|
359
|
+
const server = createMockServer();
|
|
360
|
+
const { registerGraphTools } = await loadModule();
|
|
361
|
+
registerGraphTools(
|
|
362
|
+
server,
|
|
363
|
+
graphClient
|
|
364
|
+
);
|
|
365
|
+
await server.tools.get("create-draft-email").handler({ body });
|
|
366
|
+
return auditLogMock.mock.calls[0][0];
|
|
367
|
+
};
|
|
368
|
+
it("records recipient count and domains, deduplicated and sorted", async () => {
|
|
369
|
+
draftEndpoint();
|
|
370
|
+
const payload = await runDraft({
|
|
371
|
+
toRecipients: [
|
|
372
|
+
{ emailAddress: { address: "someone@example.com" } },
|
|
373
|
+
{ emailAddress: { address: "Another@Example.com" } }
|
|
374
|
+
],
|
|
375
|
+
ccRecipients: [{ emailAddress: { address: "auditor@partner.co.uk" } }]
|
|
376
|
+
});
|
|
377
|
+
expect(payload).toMatchObject({
|
|
378
|
+
tool: "create-draft-email",
|
|
379
|
+
recipient_count: 3,
|
|
380
|
+
recipient_domains: ["example.com", "partner.co.uk"]
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
it("reads recipients nested under a camelCase message, as createReply sends them", async () => {
|
|
384
|
+
draftEndpoint();
|
|
385
|
+
const payload = await runDraft({
|
|
386
|
+
comment: "forwarding this on",
|
|
387
|
+
message: { toRecipients: [{ emailAddress: { address: "outside@gmail.com" } }] }
|
|
388
|
+
});
|
|
389
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["gmail.com"] });
|
|
390
|
+
});
|
|
391
|
+
it("reads PascalCase fields, as the Graph action endpoints send them", async () => {
|
|
392
|
+
draftEndpoint();
|
|
393
|
+
const payload = await runDraft({
|
|
394
|
+
Comment: "fyi",
|
|
395
|
+
ToRecipients: [{ emailAddress: { address: "partner@vendor.com" } }],
|
|
396
|
+
Message: { CcRecipients: [{ emailAddress: { address: "watcher@vendor.com" } }] }
|
|
397
|
+
});
|
|
398
|
+
expect(payload).toMatchObject({ recipient_count: 2, recipient_domains: ["vendor.com"] });
|
|
399
|
+
});
|
|
400
|
+
it("records calendar attendees, not just mail recipients", async () => {
|
|
401
|
+
draftEndpoint();
|
|
402
|
+
const payload = await runDraft({
|
|
403
|
+
subject: "sync",
|
|
404
|
+
attendees: [
|
|
405
|
+
{ emailAddress: { address: "colleague@example.com" }, type: "required" },
|
|
406
|
+
{ emailAddress: { address: "guest@external.org" }, type: "optional" }
|
|
407
|
+
]
|
|
408
|
+
});
|
|
409
|
+
expect(payload).toMatchObject({
|
|
410
|
+
recipient_count: 2,
|
|
411
|
+
recipient_domains: ["example.com", "external.org"]
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
it("logs domains only, never the local part of an address", async () => {
|
|
415
|
+
draftEndpoint();
|
|
416
|
+
const payload = await runDraft({
|
|
417
|
+
toRecipients: [{ emailAddress: { address: "confidential.name@example.com" } }]
|
|
418
|
+
});
|
|
419
|
+
expect(payload.recipient_domains).toEqual(["example.com"]);
|
|
420
|
+
expect(JSON.stringify(payload)).not.toContain("confidential.name");
|
|
421
|
+
});
|
|
422
|
+
it("rejects a tail that is not a plain hostname", async () => {
|
|
423
|
+
draftEndpoint();
|
|
424
|
+
const payload = await runDraft({
|
|
425
|
+
toRecipients: [
|
|
426
|
+
{ emailAddress: { address: "a@example.com/path" } },
|
|
427
|
+
{ emailAddress: { address: "b@evil<script" } },
|
|
428
|
+
{ emailAddress: { address: "c@example.com,comment" } },
|
|
429
|
+
{ emailAddress: { address: "d@[IPv6:2001:db8::1]" } },
|
|
430
|
+
{ emailAddress: { address: "e@good.example" } }
|
|
431
|
+
]
|
|
432
|
+
});
|
|
433
|
+
expect(payload.recipient_count).toBe(5);
|
|
434
|
+
expect(payload.recipient_domains).toEqual(["good.example"]);
|
|
435
|
+
});
|
|
436
|
+
it("drops a domain longer than a hostname can be", async () => {
|
|
437
|
+
draftEndpoint();
|
|
438
|
+
const payload = await runDraft({
|
|
439
|
+
toRecipients: [
|
|
440
|
+
{ emailAddress: { address: `a@${"x".repeat(300)}.example` } },
|
|
441
|
+
{ emailAddress: { address: "b@ext.com" } }
|
|
442
|
+
]
|
|
443
|
+
});
|
|
444
|
+
expect(payload.recipient_count).toBe(2);
|
|
445
|
+
expect(payload.recipient_domains).toEqual(["ext.com"]);
|
|
446
|
+
});
|
|
447
|
+
it("counts an entry that names someone without an address", async () => {
|
|
448
|
+
draftEndpoint();
|
|
449
|
+
const payload = await runDraft({
|
|
450
|
+
recipients: [{ alias: "finance-team" }, { objectId: "abc-123" }]
|
|
451
|
+
});
|
|
452
|
+
expect(payload.recipient_count).toBe(2);
|
|
453
|
+
expect(payload).not.toHaveProperty("recipient_domains");
|
|
454
|
+
});
|
|
455
|
+
it("walks a body forwarded to Graph as a raw JSON string", async () => {
|
|
456
|
+
mockEndpoints.push(
|
|
457
|
+
makeEndpoint({
|
|
458
|
+
method: "post",
|
|
459
|
+
path: "/me/sendMail",
|
|
460
|
+
alias: "send-mail",
|
|
461
|
+
parameters: [
|
|
462
|
+
{
|
|
463
|
+
name: "body",
|
|
464
|
+
type: "Body",
|
|
465
|
+
schema: z.object({ message: z.object({}).passthrough() })
|
|
466
|
+
}
|
|
467
|
+
]
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
mockEndpointsJson = [
|
|
471
|
+
makeConfig({ pathPattern: "/me/sendMail", method: "post", toolName: "send-mail" })
|
|
472
|
+
];
|
|
473
|
+
const graphClient = createMockGraphClient([
|
|
474
|
+
{
|
|
475
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
|
|
476
|
+
_meta: { http_status: 202 }
|
|
477
|
+
}
|
|
478
|
+
]);
|
|
479
|
+
const server = createMockServer();
|
|
480
|
+
const { registerGraphTools } = await loadModule();
|
|
481
|
+
registerGraphTools(
|
|
482
|
+
server,
|
|
483
|
+
graphClient
|
|
484
|
+
);
|
|
485
|
+
await server.tools.get("send-mail").handler({
|
|
486
|
+
body: JSON.stringify({
|
|
487
|
+
message: { toRecipients: [{ emailAddress: { address: "a@ext.com" } }] }
|
|
488
|
+
})
|
|
489
|
+
});
|
|
490
|
+
const payload = auditLogMock.mock.calls[0][0];
|
|
491
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
492
|
+
});
|
|
493
|
+
it("leaves a base64 upload body alone instead of warning on every upload", async () => {
|
|
494
|
+
mockEndpoints.push(
|
|
495
|
+
makeEndpoint({
|
|
496
|
+
method: "put",
|
|
497
|
+
path: "/me/photo/$value",
|
|
498
|
+
alias: "upload-my-profile-photo",
|
|
499
|
+
requestFormat: "binary",
|
|
500
|
+
parameters: [{ name: "body", type: "Body", schema: z.string() }]
|
|
501
|
+
})
|
|
502
|
+
);
|
|
503
|
+
mockEndpointsJson = [
|
|
504
|
+
makeConfig({
|
|
505
|
+
pathPattern: "/me/photo/$value",
|
|
506
|
+
method: "put",
|
|
507
|
+
toolName: "upload-my-profile-photo"
|
|
508
|
+
})
|
|
509
|
+
];
|
|
510
|
+
const graphClient = createMockGraphClient([
|
|
511
|
+
{ content: [{ type: "text", text: "{}" }], _meta: { http_status: 200 } }
|
|
512
|
+
]);
|
|
513
|
+
const server = createMockServer();
|
|
514
|
+
const { registerGraphTools } = await loadModule();
|
|
515
|
+
registerGraphTools(
|
|
516
|
+
server,
|
|
517
|
+
graphClient
|
|
518
|
+
);
|
|
519
|
+
await server.tools.get("upload-my-profile-photo").handler({
|
|
520
|
+
body: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
|
521
|
+
});
|
|
522
|
+
const payload = auditLogMock.mock.calls[0][0];
|
|
523
|
+
expect(payload).not.toHaveProperty("recipient_count");
|
|
524
|
+
expect(loggerMock.warn).not.toHaveBeenCalledWith(
|
|
525
|
+
expect.stringContaining("Skipped recipient audit metadata")
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
it("records driveItem invite recipients, which use email rather than emailAddress", async () => {
|
|
529
|
+
draftEndpoint();
|
|
530
|
+
const payload = await runDraft({
|
|
531
|
+
recipients: [{ email: "outsider@ext.com" }],
|
|
532
|
+
roles: ["read"],
|
|
533
|
+
sendInvitation: true
|
|
534
|
+
});
|
|
535
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
536
|
+
});
|
|
537
|
+
it("records meeting participants, which use upn", async () => {
|
|
538
|
+
draftEndpoint();
|
|
539
|
+
const payload = await runDraft({
|
|
540
|
+
participants: { attendees: [{ upn: "guest@ext.com" }] }
|
|
541
|
+
});
|
|
542
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
543
|
+
});
|
|
544
|
+
it("survives a deeply nested array without blowing the stack", async () => {
|
|
545
|
+
draftEndpoint();
|
|
546
|
+
let nested = [{ toRecipients: [{ emailAddress: { address: "deep@ext.com" } }] }];
|
|
547
|
+
for (let i = 0; i < 500; i++) nested = [nested];
|
|
548
|
+
const payload = await runDraft({ requests: nested });
|
|
549
|
+
expect(payload).not.toHaveProperty("recipient_count");
|
|
550
|
+
expect(payload.status).toBe("success");
|
|
551
|
+
});
|
|
552
|
+
it("still audits a call whose params cannot be serialised for the log", async () => {
|
|
553
|
+
draftEndpoint();
|
|
554
|
+
const circular = { subject: "loop" };
|
|
555
|
+
circular.self = circular;
|
|
556
|
+
const payload = await runDraft(circular);
|
|
557
|
+
expect(auditLogMock).toHaveBeenCalledTimes(1);
|
|
558
|
+
expect(payload).toMatchObject({ tool: "create-draft-email", status: "error" });
|
|
559
|
+
});
|
|
560
|
+
it("still records recipients when the request throws", async () => {
|
|
561
|
+
draftEndpoint();
|
|
562
|
+
const graphClient = {
|
|
563
|
+
graphRequest: vi.fn().mockRejectedValue(new Error("socket hang up"))
|
|
564
|
+
};
|
|
565
|
+
const server = createMockServer();
|
|
566
|
+
const { registerGraphTools } = await loadModule();
|
|
567
|
+
registerGraphTools(
|
|
568
|
+
server,
|
|
569
|
+
graphClient
|
|
570
|
+
);
|
|
571
|
+
await server.tools.get("create-draft-email").handler({
|
|
572
|
+
body: { toRecipients: [{ emailAddress: { address: "a@ext.com" } }] }
|
|
573
|
+
});
|
|
574
|
+
const payload = auditLogMock.mock.calls[0][0];
|
|
575
|
+
expect(payload.status).toBe("error");
|
|
576
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
577
|
+
});
|
|
578
|
+
it("walks recipients nested inside a graph-batch sub-request", async () => {
|
|
579
|
+
draftEndpoint();
|
|
580
|
+
const payload = await runDraft({
|
|
581
|
+
requests: [
|
|
582
|
+
{ id: "1", method: "GET", url: "/me/messages?$top=5" },
|
|
583
|
+
{
|
|
584
|
+
id: "2",
|
|
585
|
+
method: "POST",
|
|
586
|
+
url: "/me/sendMail",
|
|
587
|
+
body: { message: { toRecipients: [{ emailAddress: { address: "a@ext.com" } }] } }
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
});
|
|
591
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
592
|
+
});
|
|
593
|
+
it("reaches an itemAttachment nested inside a graph-batch sub-request", async () => {
|
|
594
|
+
draftEndpoint();
|
|
595
|
+
const payload = await runDraft({
|
|
596
|
+
requests: [
|
|
597
|
+
{
|
|
598
|
+
id: "1",
|
|
599
|
+
method: "POST",
|
|
600
|
+
url: "/me/sendMail",
|
|
601
|
+
body: {
|
|
602
|
+
message: {
|
|
603
|
+
toRecipients: [{ emailAddress: { address: "direct@ext.com" } }],
|
|
604
|
+
attachments: [
|
|
605
|
+
{
|
|
606
|
+
"@odata.type": "#microsoft.graph.itemAttachment",
|
|
607
|
+
item: {
|
|
608
|
+
toRecipients: [{ emailAddress: { address: "forwarded@deeper.example" } }]
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
]
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
]
|
|
616
|
+
});
|
|
617
|
+
expect(payload.recipient_count).toBe(2);
|
|
618
|
+
expect(payload.recipient_domains).toEqual(["deeper.example", "ext.com"]);
|
|
619
|
+
});
|
|
620
|
+
it("reads a bare string entry, malformed though it is", async () => {
|
|
621
|
+
draftEndpoint();
|
|
622
|
+
const payload = await runDraft({ toRecipients: ["a@ext.com"] });
|
|
623
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
624
|
+
});
|
|
625
|
+
it("reads an all-PascalCase recipient entry, as Graph accepts it", async () => {
|
|
626
|
+
draftEndpoint();
|
|
627
|
+
const payload = await runDraft({
|
|
628
|
+
ToRecipients: [{ EmailAddress: { Address: "a@ext.com" } }]
|
|
629
|
+
});
|
|
630
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
631
|
+
});
|
|
632
|
+
it("normalises a display-name address down to the bare domain", async () => {
|
|
633
|
+
draftEndpoint();
|
|
634
|
+
const payload = await runDraft({
|
|
635
|
+
toRecipients: [
|
|
636
|
+
{ emailAddress: { address: "Bob <bob@ext.com>" } },
|
|
637
|
+
{ emailAddress: { address: "a@ext.com " } },
|
|
638
|
+
{ emailAddress: { address: "c@ext.com note" } }
|
|
639
|
+
]
|
|
640
|
+
});
|
|
641
|
+
expect(payload.recipient_domains).toEqual(["ext.com"]);
|
|
642
|
+
expect(payload.recipient_count).toBe(3);
|
|
643
|
+
});
|
|
644
|
+
it("caps the domain list but keeps recipient_count exact", async () => {
|
|
645
|
+
draftEndpoint();
|
|
646
|
+
const payload = await runDraft({
|
|
647
|
+
toRecipients: Array.from({ length: 60 }, (_, i) => ({
|
|
648
|
+
emailAddress: { address: `user@d${String(i).padStart(2, "0")}.example` }
|
|
649
|
+
}))
|
|
650
|
+
});
|
|
651
|
+
expect(payload.recipient_count).toBe(60);
|
|
652
|
+
expect(payload.recipient_domains).toHaveLength(50);
|
|
653
|
+
expect(payload.recipient_domains_truncated).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
it("does not flag truncation when the domain list fits", async () => {
|
|
656
|
+
draftEndpoint();
|
|
657
|
+
const payload = await runDraft({
|
|
658
|
+
toRecipients: [{ emailAddress: { address: "a@example.com" } }]
|
|
659
|
+
});
|
|
660
|
+
expect(payload).not.toHaveProperty("recipient_domains_truncated");
|
|
661
|
+
});
|
|
662
|
+
it("omits both fields when a request has no recipients", async () => {
|
|
663
|
+
draftEndpoint();
|
|
664
|
+
const payload = await runDraft({ subject: "a draft with no recipients yet" });
|
|
665
|
+
expect(payload).not.toHaveProperty("recipient_count");
|
|
666
|
+
expect(payload).not.toHaveProperty("recipient_domains");
|
|
667
|
+
});
|
|
668
|
+
});
|
|
336
669
|
describe("$count advanced query mode", () => {
|
|
337
670
|
it("should set ConsistencyLevel: eventual header when $count=true", async () => {
|
|
338
671
|
const endpoint = makeEndpoint();
|
package/dist/graph-tools.js
CHANGED
|
@@ -115,6 +115,86 @@ function graphResponseAuditFields(response) {
|
|
|
115
115
|
...graphBatchErrorCodeCounts !== void 0 ? { graph_batch_error_code_counts: graphBatchErrorCodeCounts } : {}
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
+
const RECIPIENT_FIELDS = /* @__PURE__ */ new Set([
|
|
119
|
+
"torecipients",
|
|
120
|
+
"ccrecipients",
|
|
121
|
+
"bccrecipients",
|
|
122
|
+
"attendees",
|
|
123
|
+
// driveItem /invite mails an outsider a link to the file
|
|
124
|
+
"recipients"
|
|
125
|
+
]);
|
|
126
|
+
const MAX_BODY_DEPTH = 8;
|
|
127
|
+
const MAX_RECIPIENT_DOMAINS = 50;
|
|
128
|
+
function lookupCaseInsensitive(node, lowerName) {
|
|
129
|
+
if (!node || typeof node !== "object") return void 0;
|
|
130
|
+
for (const [key, value] of Object.entries(node)) {
|
|
131
|
+
if (key.toLowerCase() === lowerName) return value;
|
|
132
|
+
}
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
function readAddress(entry) {
|
|
136
|
+
if (typeof entry === "string") return entry;
|
|
137
|
+
const emailAddress = lookupCaseInsensitive(entry, "emailaddress");
|
|
138
|
+
const candidates = [
|
|
139
|
+
typeof emailAddress === "string" ? emailAddress : lookupCaseInsensitive(emailAddress, "address"),
|
|
140
|
+
lookupCaseInsensitive(entry, "email"),
|
|
141
|
+
lookupCaseInsensitive(entry, "upn")
|
|
142
|
+
];
|
|
143
|
+
for (const candidate of candidates) {
|
|
144
|
+
if (typeof candidate === "string" && candidate) return candidate;
|
|
145
|
+
}
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
149
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
150
|
+
function addRecipient(entry, domains, counter) {
|
|
151
|
+
counter.count += 1;
|
|
152
|
+
const address = readAddress(entry);
|
|
153
|
+
if (address === void 0) return;
|
|
154
|
+
const at = address.lastIndexOf("@");
|
|
155
|
+
if (at <= 0 || at >= address.length - 1) return;
|
|
156
|
+
const domain = address.slice(at + 1).trim().split(/\s/)[0].replace(/[>.]+$/, "").toLowerCase();
|
|
157
|
+
if (domain.length <= MAX_DOMAIN_LENGTH && DOMAIN_PATTERN.test(domain)) domains.add(domain);
|
|
158
|
+
}
|
|
159
|
+
function collectRecipients(node, domains, counter, depth = 0) {
|
|
160
|
+
if (!node || typeof node !== "object" || depth > MAX_BODY_DEPTH) return;
|
|
161
|
+
if (Array.isArray(node)) {
|
|
162
|
+
for (const item of node) collectRecipients(item, domains, counter, depth + 1);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const [key, value] of Object.entries(node)) {
|
|
166
|
+
if (RECIPIENT_FIELDS.has(key.toLowerCase()) && Array.isArray(value)) {
|
|
167
|
+
for (const entry of value) addRecipient(entry, domains, counter);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
collectRecipients(value, domains, counter, depth + 1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function bodyForRecipientWalk(body) {
|
|
174
|
+
if (typeof body !== "string") return body;
|
|
175
|
+
if (!/^\s*[{[]/.test(body)) return void 0;
|
|
176
|
+
return JSON.parse(body);
|
|
177
|
+
}
|
|
178
|
+
function recipientAuditFields(body) {
|
|
179
|
+
const domains = /* @__PURE__ */ new Set();
|
|
180
|
+
const counter = { count: 0 };
|
|
181
|
+
try {
|
|
182
|
+
collectRecipients(bodyForRecipientWalk(body), domains, counter);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
logger.warn(
|
|
185
|
+
`Skipped recipient audit metadata: ${error instanceof Error ? error.message : "unknown error"}`
|
|
186
|
+
);
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
if (counter.count === 0) return {};
|
|
190
|
+
const sorted = [...domains].sort();
|
|
191
|
+
const capped = sorted.slice(0, MAX_RECIPIENT_DOMAINS);
|
|
192
|
+
return {
|
|
193
|
+
recipient_count: counter.count,
|
|
194
|
+
...capped.length > 0 ? { recipient_domains: capped } : {},
|
|
195
|
+
...sorted.length > capped.length ? { recipient_domains_truncated: true } : {}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
118
198
|
function thrownErrorAuditFields(error) {
|
|
119
199
|
const err = error;
|
|
120
200
|
const httpStatus = auditHttpStatus(err?.httpStatus ?? err?.status);
|
|
@@ -745,8 +825,15 @@ function isPlainObject(value) {
|
|
|
745
825
|
function hasOwn(obj, key) {
|
|
746
826
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
747
827
|
}
|
|
828
|
+
function describeParamsForLog(params) {
|
|
829
|
+
try {
|
|
830
|
+
return JSON.stringify(params);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
return `[unserializable: ${error instanceof Error ? error.name : "unknown error"}]`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
748
835
|
async function executeGraphTool(tool, config, graphClient, params, authManager) {
|
|
749
|
-
logger.info(`Tool ${tool.alias} called with params: ${
|
|
836
|
+
logger.info(`Tool ${tool.alias} called with params: ${describeParamsForLog(params)}`);
|
|
750
837
|
if (isConfirmGateEnabled() && isDestructiveOperation(tool.method, config) && params.confirm !== true) {
|
|
751
838
|
logger.warn(
|
|
752
839
|
`Refusing destructive tool ${tool.alias} (${tool.method.toUpperCase()}): missing confirm: true`
|
|
@@ -772,6 +859,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
772
859
|
const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
|
|
773
860
|
const httpMethod = tool.method.toUpperCase();
|
|
774
861
|
let targetResource;
|
|
862
|
+
let body = null;
|
|
775
863
|
try {
|
|
776
864
|
const accountParam = params.account;
|
|
777
865
|
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
@@ -801,7 +889,6 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
801
889
|
let path2 = tool.path;
|
|
802
890
|
const queryParams = {};
|
|
803
891
|
const headers = {};
|
|
804
|
-
let body = null;
|
|
805
892
|
const bodyShape = bodySchemaShape(
|
|
806
893
|
parameterDefinitions.find((p) => p.type === "Body")?.schema
|
|
807
894
|
);
|
|
@@ -1130,7 +1217,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1130
1217
|
status: response.isError ? "error" : "success",
|
|
1131
1218
|
duration_ms: Date.now() - startTime,
|
|
1132
1219
|
...targetResource ? { target_resource: targetResource } : {},
|
|
1133
|
-
...graphResponseAuditFields(response)
|
|
1220
|
+
...graphResponseAuditFields(response),
|
|
1221
|
+
...recipientAuditFields(body)
|
|
1134
1222
|
});
|
|
1135
1223
|
return {
|
|
1136
1224
|
content,
|
|
@@ -1150,7 +1238,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1150
1238
|
duration_ms: Date.now() - startTime,
|
|
1151
1239
|
...targetResource ? { target_resource: targetResource } : {},
|
|
1152
1240
|
error_type: err?.name || "Error",
|
|
1153
|
-
...thrownErrorAuditFields(error)
|
|
1241
|
+
...thrownErrorAuditFields(error),
|
|
1242
|
+
...recipientAuditFields(body)
|
|
1154
1243
|
});
|
|
1155
1244
|
return {
|
|
1156
1245
|
content: [
|
package/docs/deployment.md
CHANGED
|
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
213
213
|
- **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
|
|
214
214
|
- **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
|
|
215
215
|
- **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
|
|
216
|
-
- **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, target_resource?, error_type?, error_code? }`. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values,
|
|
216
|
+
- **Structured audit log**: enabled by default. Every tool invocation that reaches Microsoft Graph emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`, or under `MS365_MCP_LOG_DIR` when set) with `{ event, request_id, user_principal_name, tool, http_method, http_status?, status, duration_ms, recipient_count?, recipient_domains?, recipient_domains_truncated?, graph_batch_subrequest_count?, graph_batch_http_status_counts?, graph_batch_error_code_counts?, target_resource?, error_type?, error_code? }`. A few refusals short-circuit before that and emit nothing: a confirm-gate rejection, an `account` param that contradicts the bearer identity, and a failure to resolve an account token. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Two things derived from tool parameters **are** recorded, both deliberately. First, `target_resource.id` substitutes ID-like path parameters into the resource path, so a tool on `/users/{user-id}/...` records whatever identifier the caller passed, which may be a full email address. Second, a request whose body carries `toRecipients` / `ccRecipients` / `bccRecipients` / `attendees` / `recipients`, at any casing and several levels down, including inside a `graph-batch` sub-request, records `recipient_count`, the number of entries in those arrays, and `recipient_domains`, the **domain part only** of their addresses and only where it parses as a plain hostname, never the local part and never a subject or message body. An entry that names someone without an address (a `driveRecipient` given as `alias` or `objectId`) counts but contributes no domain. It keys on body shape rather than on the endpoint, so it covers sends, forwards, invites and file shares but equally draft creation and edits, event updates and `findMeetingTimes`, and it reads high rather than low: an attached message's own recipients are counted too. `recipient_domains` holds at most 50 **distinct domains**, alphabetically, and sets `recipient_domains_truncated: true` when there were more; `recipient_count` is unaffected by the cap. All three are absent when the body carries no recipient array at all. A request that fails after reaching Graph still records recipients, since a timeout is not proof of non-delivery. Gaps remain, so absence proves nothing: a draft composed outside this server and sent by id, the original thread's recipients on a reply (Graph resolves those server-side), and a body nesting recipients deeper than the walker descends. This describes the structured audit log only; the operational logger is separate and does log tool parameters. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
|
|
217
217
|
- **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
|
|
218
218
|
- **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
|
|
219
219
|
|
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.146.1",
|
|
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",
|