@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({
@@ -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)) {
@@ -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
- ...result.httpStatus !== void 0 ? { _meta: { http_status: result.httpStatus } } : {}
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
  }
@@ -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.151.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",