@softeria/ms-365-mcp-server 0.151.0 → 0.152.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.
|
@@ -42,6 +42,52 @@ describe("GraphClient audit metadata", () => {
|
|
|
42
42
|
expect(result._meta).toMatchObject({ http_status: 200 });
|
|
43
43
|
expect(JSON.parse(result.content[0].text)).toEqual({ id: "user-1" });
|
|
44
44
|
});
|
|
45
|
+
it("derives result volume from a collection response", async () => {
|
|
46
|
+
const body = JSON.stringify({
|
|
47
|
+
value: [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
|
|
48
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=3"
|
|
49
|
+
});
|
|
50
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
51
|
+
new Response(body, { status: 200, headers: { "content-type": "application/json" } })
|
|
52
|
+
);
|
|
53
|
+
const result = await createGraphClient().graphRequest("/me/messages");
|
|
54
|
+
expect(result._meta).toMatchObject({
|
|
55
|
+
result_count: 3,
|
|
56
|
+
result_has_more: true,
|
|
57
|
+
response_bytes: Buffer.byteLength(body, "utf8")
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it("reports result_has_more false, not absent, for a complete collection", async () => {
|
|
61
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
62
|
+
new Response(JSON.stringify({ value: [{ id: "m1" }] }), {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { "content-type": "application/json" }
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
const result = await createGraphClient().graphRequest("/me/messages");
|
|
68
|
+
expect(result._meta).toMatchObject({ result_count: 1, result_has_more: false });
|
|
69
|
+
});
|
|
70
|
+
it("omits result fields for a single-object response but still records size", async () => {
|
|
71
|
+
const body = JSON.stringify({ id: "user-1" });
|
|
72
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
73
|
+
new Response(body, { status: 200, headers: { "content-type": "application/json" } })
|
|
74
|
+
);
|
|
75
|
+
const result = await createGraphClient().graphRequest("/me");
|
|
76
|
+
expect(result._meta).toMatchObject({ response_bytes: Buffer.byteLength(body, "utf8") });
|
|
77
|
+
expect(result._meta).not.toHaveProperty("result_count");
|
|
78
|
+
expect(result._meta).not.toHaveProperty("result_has_more");
|
|
79
|
+
});
|
|
80
|
+
it("records the pre-base64 byte count for binary content", async () => {
|
|
81
|
+
const raw = Buffer.from("binary-attachment-payload-\xFF\xFE");
|
|
82
|
+
fetchWithResilienceMock.mockResolvedValue(
|
|
83
|
+
new Response(raw, {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: { "content-type": "application/octet-stream" }
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
const result = await createGraphClient().graphRequest("/me/messages/m1/$value");
|
|
89
|
+
expect(result._meta).toMatchObject({ response_bytes: raw.byteLength });
|
|
90
|
+
});
|
|
45
91
|
it("preserves HTTP status metadata when response headers are requested", async () => {
|
|
46
92
|
fetchWithResilienceMock.mockResolvedValue(
|
|
47
93
|
new Response(JSON.stringify({ id: "task-1" }), {
|
|
@@ -333,6 +333,172 @@ describe("graph-tools", () => {
|
|
|
333
333
|
);
|
|
334
334
|
});
|
|
335
335
|
});
|
|
336
|
+
describe("audit response volume", () => {
|
|
337
|
+
it("lifts result volume from _meta onto the audit event", async () => {
|
|
338
|
+
const endpoint = makeEndpoint({
|
|
339
|
+
method: "get",
|
|
340
|
+
path: "/me/messages",
|
|
341
|
+
alias: "list-mail-messages"
|
|
342
|
+
});
|
|
343
|
+
const config = makeConfig({
|
|
344
|
+
pathPattern: "/me/messages",
|
|
345
|
+
method: "get",
|
|
346
|
+
toolName: "list-mail-messages"
|
|
347
|
+
});
|
|
348
|
+
mockEndpoints.push(endpoint);
|
|
349
|
+
mockEndpointsJson = [config];
|
|
350
|
+
const graphClient = createMockGraphClient([
|
|
351
|
+
{
|
|
352
|
+
content: [{ type: "text", text: JSON.stringify({ value: [{ id: "m1" }] }) }],
|
|
353
|
+
_meta: {
|
|
354
|
+
http_status: 200,
|
|
355
|
+
result_count: 4821,
|
|
356
|
+
result_has_more: true,
|
|
357
|
+
response_bytes: 8412004
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
]);
|
|
361
|
+
const server = createMockServer();
|
|
362
|
+
const { registerGraphTools } = await loadModule();
|
|
363
|
+
registerGraphTools(
|
|
364
|
+
server,
|
|
365
|
+
graphClient
|
|
366
|
+
);
|
|
367
|
+
await server.tools.get("list-mail-messages").handler({});
|
|
368
|
+
expect(auditLogMock).toHaveBeenCalledWith(
|
|
369
|
+
expect.objectContaining({
|
|
370
|
+
tool: "list-mail-messages",
|
|
371
|
+
status: "success",
|
|
372
|
+
result_count: 4821,
|
|
373
|
+
result_has_more: true,
|
|
374
|
+
response_bytes: 8412004
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
it("keeps result_has_more when it is false rather than dropping it", async () => {
|
|
379
|
+
const endpoint = makeEndpoint({
|
|
380
|
+
method: "get",
|
|
381
|
+
path: "/me/messages",
|
|
382
|
+
alias: "list-mail-messages"
|
|
383
|
+
});
|
|
384
|
+
const config = makeConfig({
|
|
385
|
+
pathPattern: "/me/messages",
|
|
386
|
+
method: "get",
|
|
387
|
+
toolName: "list-mail-messages"
|
|
388
|
+
});
|
|
389
|
+
mockEndpoints.push(endpoint);
|
|
390
|
+
mockEndpointsJson = [config];
|
|
391
|
+
const graphClient = createMockGraphClient([
|
|
392
|
+
{
|
|
393
|
+
content: [{ type: "text", text: JSON.stringify({ value: [] }) }],
|
|
394
|
+
_meta: { http_status: 200, result_count: 0, result_has_more: false }
|
|
395
|
+
}
|
|
396
|
+
]);
|
|
397
|
+
const server = createMockServer();
|
|
398
|
+
const { registerGraphTools } = await loadModule();
|
|
399
|
+
registerGraphTools(
|
|
400
|
+
server,
|
|
401
|
+
graphClient
|
|
402
|
+
);
|
|
403
|
+
await server.tools.get("list-mail-messages").handler({});
|
|
404
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
405
|
+
expect(payload.result_count).toBe(0);
|
|
406
|
+
expect(payload.result_has_more).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
it("restates count and bytes for the whole read when pages are merged", async () => {
|
|
409
|
+
mockEndpoints.push(makeEndpoint());
|
|
410
|
+
mockEndpointsJson = [makeConfig()];
|
|
411
|
+
const graphClient = createMockGraphClient([
|
|
412
|
+
{
|
|
413
|
+
content: [
|
|
414
|
+
{
|
|
415
|
+
type: "text",
|
|
416
|
+
text: JSON.stringify({
|
|
417
|
+
value: [{ id: "1" }, { id: "2" }],
|
|
418
|
+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=2"
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
],
|
|
422
|
+
_meta: { http_status: 200, result_count: 2, result_has_more: true, response_bytes: 1e3 }
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
content: [{ type: "text", text: JSON.stringify({ value: [{ id: "3" }] }) }],
|
|
426
|
+
_meta: { http_status: 200, result_count: 1, result_has_more: false, response_bytes: 700 }
|
|
427
|
+
}
|
|
428
|
+
]);
|
|
429
|
+
const server = createMockServer();
|
|
430
|
+
const { registerGraphTools } = await loadModule();
|
|
431
|
+
registerGraphTools(server, graphClient);
|
|
432
|
+
await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
433
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
434
|
+
expect(payload.result_count).toBe(3);
|
|
435
|
+
expect(payload.result_has_more).toBe(false);
|
|
436
|
+
expect(payload.response_bytes).toBe(1700);
|
|
437
|
+
});
|
|
438
|
+
it("reports result_has_more when the merge loop stopped on a page cap", async () => {
|
|
439
|
+
const prevMaxPages = process.env.MS365_MCP_MAX_PAGES;
|
|
440
|
+
process.env.MS365_MCP_MAX_PAGES = "2";
|
|
441
|
+
try {
|
|
442
|
+
mockEndpoints.push(makeEndpoint());
|
|
443
|
+
mockEndpointsJson = [makeConfig()];
|
|
444
|
+
const graphClient = createMockGraphClient(
|
|
445
|
+
Array.from({ length: 5 }, (_, i) => ({
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
text: JSON.stringify({
|
|
450
|
+
value: [{ id: `item-${i}` }],
|
|
451
|
+
"@odata.nextLink": `https://graph.microsoft.com/v1.0/me/messages?$skip=${i + 1}`
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
],
|
|
455
|
+
_meta: { http_status: 200, result_count: 1, result_has_more: true, response_bytes: 50 }
|
|
456
|
+
}))
|
|
457
|
+
);
|
|
458
|
+
const server = createMockServer();
|
|
459
|
+
const { registerGraphTools } = await loadModule();
|
|
460
|
+
registerGraphTools(server, graphClient);
|
|
461
|
+
await server.tools.get("test-tool").handler({ fetchAllPages: true });
|
|
462
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
463
|
+
expect(payload.result_count).toBe(2);
|
|
464
|
+
expect(payload.result_has_more).toBe(true);
|
|
465
|
+
expect(payload.response_bytes).toBe(100);
|
|
466
|
+
} finally {
|
|
467
|
+
if (prevMaxPages === void 0) {
|
|
468
|
+
delete process.env.MS365_MCP_MAX_PAGES;
|
|
469
|
+
} else {
|
|
470
|
+
process.env.MS365_MCP_MAX_PAGES = prevMaxPages;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
it("omits the volume fields when the client supplied none", async () => {
|
|
475
|
+
const endpoint = makeEndpoint({ method: "get", path: "/me", alias: "get-current-user" });
|
|
476
|
+
const config = makeConfig({
|
|
477
|
+
pathPattern: "/me",
|
|
478
|
+
method: "get",
|
|
479
|
+
toolName: "get-current-user"
|
|
480
|
+
});
|
|
481
|
+
mockEndpoints.push(endpoint);
|
|
482
|
+
mockEndpointsJson = [config];
|
|
483
|
+
const graphClient = createMockGraphClient([
|
|
484
|
+
{
|
|
485
|
+
content: [{ type: "text", text: JSON.stringify({ id: "user-1" }) }],
|
|
486
|
+
_meta: { http_status: 200 }
|
|
487
|
+
}
|
|
488
|
+
]);
|
|
489
|
+
const server = createMockServer();
|
|
490
|
+
const { registerGraphTools } = await loadModule();
|
|
491
|
+
registerGraphTools(
|
|
492
|
+
server,
|
|
493
|
+
graphClient
|
|
494
|
+
);
|
|
495
|
+
await server.tools.get("get-current-user").handler({});
|
|
496
|
+
const [payload] = auditLogMock.mock.calls[0];
|
|
497
|
+
expect(payload).not.toHaveProperty("result_count");
|
|
498
|
+
expect(payload).not.toHaveProperty("result_has_more");
|
|
499
|
+
expect(payload).not.toHaveProperty("response_bytes");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
336
502
|
describe("audit recipient metadata", () => {
|
|
337
503
|
const draftEndpoint = () => {
|
|
338
504
|
const endpoint = makeEndpoint({
|
package/dist/graph-client.js
CHANGED
|
@@ -66,6 +66,13 @@ function extractGraphErrorCodeFromBody(body) {
|
|
|
66
66
|
const code = isRecord(error) ? error.code : body.code;
|
|
67
67
|
return typeof code === "string" ? code : void 0;
|
|
68
68
|
}
|
|
69
|
+
function extractPayloadMetadata(data) {
|
|
70
|
+
if (!isRecord(data) || !Array.isArray(data.value)) return {};
|
|
71
|
+
return {
|
|
72
|
+
result_count: data.value.length,
|
|
73
|
+
result_has_more: typeof data["@odata.nextLink"] === "string"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
69
76
|
function extractBatchMetadata(data) {
|
|
70
77
|
if (!isRecord(data) || !Array.isArray(data.responses)) return {};
|
|
71
78
|
const httpStatusCounts = {};
|
|
@@ -125,8 +132,10 @@ class GraphClient {
|
|
|
125
132
|
contentLength: buffer.byteLength,
|
|
126
133
|
contentBytes: buffer.toString("base64")
|
|
127
134
|
};
|
|
135
|
+
metadata = { ...metadata, response_bytes: buffer.byteLength };
|
|
128
136
|
} else {
|
|
129
137
|
const text = await response.text();
|
|
138
|
+
metadata = { ...metadata, response_bytes: Buffer.byteLength(text, "utf8") };
|
|
130
139
|
if (text === "") {
|
|
131
140
|
result = { message: TRANSPORT_OK_MESSAGE };
|
|
132
141
|
} else if (options.rawResponse) {
|
|
@@ -142,6 +151,7 @@ class GraphClient {
|
|
|
142
151
|
if (endpoint === "/$batch") {
|
|
143
152
|
metadata = { ...metadata, ...extractBatchMetadata(result) };
|
|
144
153
|
}
|
|
154
|
+
metadata = { ...metadata, ...extractPayloadMetadata(result) };
|
|
145
155
|
if (options.includeHeaders) {
|
|
146
156
|
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
|
147
157
|
if (result && typeof result === "object" && !Array.isArray(result)) {
|
package/dist/graph-tools.js
CHANGED
|
@@ -256,7 +256,13 @@ function graphResponseAuditFields(response) {
|
|
|
256
256
|
const graphBatchErrorCodeCounts = auditStringNumberMap(
|
|
257
257
|
response._meta?.graph_batch_error_code_counts
|
|
258
258
|
);
|
|
259
|
+
const resultCount = auditNonNegativeInteger(response._meta?.result_count);
|
|
260
|
+
const responseBytes = auditNonNegativeInteger(response._meta?.response_bytes);
|
|
261
|
+
const resultHasMore = typeof response._meta?.result_has_more === "boolean" ? response._meta.result_has_more : void 0;
|
|
259
262
|
return {
|
|
263
|
+
...resultCount !== void 0 ? { result_count: resultCount } : {},
|
|
264
|
+
...resultHasMore !== void 0 ? { result_has_more: resultHasMore } : {},
|
|
265
|
+
...responseBytes !== void 0 ? { response_bytes: responseBytes } : {},
|
|
260
266
|
...httpStatus !== void 0 ? { http_status: httpStatus } : {},
|
|
261
267
|
...errorCode !== void 0 ? { error_code: errorCode } : {},
|
|
262
268
|
...graphBatchSubrequestCount !== void 0 ? { graph_batch_subrequest_count: graphBatchSubrequestCount } : {},
|
|
@@ -770,7 +776,14 @@ const UTILITY_TOOLS = [
|
|
|
770
776
|
})
|
|
771
777
|
}
|
|
772
778
|
],
|
|
773
|
-
|
|
779
|
+
// response_bytes must describe the file written, not this receipt.
|
|
780
|
+
// Streaming to disk means the payload never appears in the response,
|
|
781
|
+
// so without this the most extraction-shaped tool in the server would
|
|
782
|
+
// audit a 250MB download at the size of an error message.
|
|
783
|
+
_meta: {
|
|
784
|
+
...result.httpStatus !== void 0 ? { http_status: result.httpStatus } : {},
|
|
785
|
+
...typeof result.contentLength === "number" ? { response_bytes: result.contentLength } : {}
|
|
786
|
+
}
|
|
774
787
|
};
|
|
775
788
|
} catch (error) {
|
|
776
789
|
const metadata = thrownErrorAuditFields(error);
|
|
@@ -1376,6 +1389,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1376
1389
|
let allItems = firstValue;
|
|
1377
1390
|
let nextLink = combinedResponse["@odata.nextLink"];
|
|
1378
1391
|
let pageCount = 1;
|
|
1392
|
+
let totalResponseBytes = response._meta?.response_bytes;
|
|
1379
1393
|
const maxPages = positiveIntFromEnv("MS365_MCP_MAX_PAGES", DEFAULT_MAX_PAGES);
|
|
1380
1394
|
const maxItems = positiveIntFromEnv("MS365_MCP_MAX_ITEMS", DEFAULT_MAX_ITEMS);
|
|
1381
1395
|
let deltaLink = combinedResponse["@odata.deltaLink"];
|
|
@@ -1396,6 +1410,9 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1396
1410
|
allItems = allItems.concat(nextJsonResponse.value);
|
|
1397
1411
|
}
|
|
1398
1412
|
nextLink = nextJsonResponse["@odata.nextLink"];
|
|
1413
|
+
if (typeof totalResponseBytes === "number" && typeof nextResponse._meta?.response_bytes === "number") {
|
|
1414
|
+
totalResponseBytes += nextResponse._meta.response_bytes;
|
|
1415
|
+
}
|
|
1399
1416
|
if (nextJsonResponse["@odata.deltaLink"]) {
|
|
1400
1417
|
deltaLink = nextJsonResponse["@odata.deltaLink"];
|
|
1401
1418
|
}
|
|
@@ -1418,6 +1435,12 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1418
1435
|
combinedResponse["@odata.count"] = allItems.length;
|
|
1419
1436
|
}
|
|
1420
1437
|
delete combinedResponse["@odata.nextLink"];
|
|
1438
|
+
response._meta = {
|
|
1439
|
+
...response._meta,
|
|
1440
|
+
result_count: allItems.length,
|
|
1441
|
+
result_has_more: Boolean(nextLink),
|
|
1442
|
+
...totalResponseBytes !== void 0 ? { response_bytes: totalResponseBytes } : {}
|
|
1443
|
+
};
|
|
1421
1444
|
if (deltaLink) {
|
|
1422
1445
|
combinedResponse["@odata.deltaLink"] = deltaLink;
|
|
1423
1446
|
}
|
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 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`
|
|
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?, result_count?, result_has_more?, response_bytes?, 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. Response _metadata_ is recorded — `result_count`, `result_has_more` and `response_bytes` describe how much came back so that a bulk read is distinguishable from an ordinary one; none of them reveals any of its content. 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.152.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",
|