@softeria/ms-365-mcp-server 0.135.0 → 0.136.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.
@@ -89,6 +89,10 @@ async function loadModule() {
89
89
  const mod = await import("../graph-tools.js");
90
90
  return mod;
91
91
  }
92
+ async function spyOnAuditLogger() {
93
+ const { __testing } = await import("../audit-log.js");
94
+ return vi.spyOn(__testing.auditLogger, "info").mockImplementation(() => __testing.auditLogger);
95
+ }
92
96
  function createMockServer() {
93
97
  const tools = /* @__PURE__ */ new Map();
94
98
  return {
@@ -139,6 +143,234 @@ describe("graph-tools", () => {
139
143
  expect(url).toContain("$count=true");
140
144
  });
141
145
  });
146
+ describe("audit target resources", () => {
147
+ it("adds target_resource to generated Graph tool audit events", async () => {
148
+ const endpoint = makeEndpoint({
149
+ alias: "get-drive-item",
150
+ path: "/drives/:driveId/items/:driveItemId",
151
+ parameters: [
152
+ { name: "driveId", type: "Path", schema: z.string() },
153
+ { name: "driveItemId", type: "Path", schema: z.string() }
154
+ ]
155
+ });
156
+ const config = makeConfig({
157
+ toolName: "get-drive-item",
158
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
159
+ scopes: ["Files.Read"]
160
+ });
161
+ mockEndpoints.push(endpoint);
162
+ mockEndpointsJson = [config];
163
+ const graphClient = createMockGraphClient([
164
+ { content: [{ type: "text", text: JSON.stringify({ id: "item-2" }) }] }
165
+ ]);
166
+ const server = createMockServer();
167
+ const { registerGraphTools } = await loadModule();
168
+ registerGraphTools(server, graphClient);
169
+ const auditSpy = await spyOnAuditLogger();
170
+ await server.tools.get("get-drive-item").handler({
171
+ driveId: "drive-1",
172
+ driveItemId: "item-2"
173
+ });
174
+ expect(auditSpy).toHaveBeenCalledWith(
175
+ expect.objectContaining({
176
+ event: "tool.call",
177
+ tool: "get-drive-item",
178
+ status: "success",
179
+ target_resource: {
180
+ type: "drive_item",
181
+ id: "/drives/drive-1/items/item-2"
182
+ }
183
+ })
184
+ );
185
+ auditSpy.mockRestore();
186
+ });
187
+ it("adds target_resource to failed generated Graph tool audit events", async () => {
188
+ const endpoint = makeEndpoint({
189
+ alias: "get-drive-item",
190
+ path: "/drives/:driveId/items/:driveItemId",
191
+ parameters: [
192
+ { name: "driveId", type: "Path", schema: z.string() },
193
+ { name: "driveItemId", type: "Path", schema: z.string() }
194
+ ]
195
+ });
196
+ const config = makeConfig({
197
+ toolName: "get-drive-item",
198
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
199
+ scopes: ["Files.Read"]
200
+ });
201
+ mockEndpoints.push(endpoint);
202
+ mockEndpointsJson = [config];
203
+ const graphClient = createMockGraphClient();
204
+ graphClient.graphRequest.mockRejectedValueOnce(
205
+ Object.assign(new Error("Forbidden"), { status: 403 })
206
+ );
207
+ const server = createMockServer();
208
+ const { registerGraphTools } = await loadModule();
209
+ registerGraphTools(
210
+ server,
211
+ graphClient
212
+ );
213
+ const auditSpy = await spyOnAuditLogger();
214
+ const result = await server.tools.get("get-drive-item").handler({
215
+ driveId: "drive-1",
216
+ driveItemId: "item-2"
217
+ });
218
+ expect(result.isError).toBe(true);
219
+ expect(auditSpy).toHaveBeenCalledWith(
220
+ expect.objectContaining({
221
+ event: "tool.call",
222
+ tool: "get-drive-item",
223
+ status: "error",
224
+ error_code: 403,
225
+ target_resource: {
226
+ type: "drive_item",
227
+ id: "/drives/drive-1/items/item-2"
228
+ }
229
+ })
230
+ );
231
+ auditSpy.mockRestore();
232
+ });
233
+ it("derives target_resource from generic ID path parameters", async () => {
234
+ const endpoint = makeEndpoint({
235
+ alias: "get-mail-message",
236
+ path: "/me/messages/:messageId",
237
+ parameters: [{ name: "messageId", type: "Path", schema: z.string() }]
238
+ });
239
+ const config = makeConfig({
240
+ toolName: "get-mail-message",
241
+ pathPattern: "/me/messages/{message-id}"
242
+ });
243
+ mockEndpoints.push(endpoint);
244
+ mockEndpointsJson = [config];
245
+ const graphClient = createMockGraphClient([
246
+ { content: [{ type: "text", text: JSON.stringify({ id: "message-1" }) }] }
247
+ ]);
248
+ const server = createMockServer();
249
+ const { registerGraphTools } = await loadModule();
250
+ registerGraphTools(server, graphClient);
251
+ const auditSpy = await spyOnAuditLogger();
252
+ await server.tools.get("get-mail-message").handler({
253
+ messageId: "message-1"
254
+ });
255
+ expect(auditSpy).toHaveBeenCalledWith(
256
+ expect.objectContaining({
257
+ event: "tool.call",
258
+ tool: "get-mail-message",
259
+ status: "success",
260
+ target_resource: {
261
+ type: "message",
262
+ id: "/me/messages/message-1"
263
+ }
264
+ })
265
+ );
266
+ auditSpy.mockRestore();
267
+ });
268
+ it("omits target_resource when an ID path parameter is missing", async () => {
269
+ const endpoint = makeEndpoint({
270
+ alias: "get-drive-item",
271
+ path: "/drives/:driveId/items/:driveItemId",
272
+ parameters: [
273
+ { name: "driveId", type: "Path", schema: z.string() },
274
+ { name: "driveItemId", type: "Path", schema: z.string() }
275
+ ]
276
+ });
277
+ const config = makeConfig({
278
+ toolName: "get-drive-item",
279
+ pathPattern: "/drives/{drive-id}/items/{driveItem-id}",
280
+ scopes: ["Files.Read"]
281
+ });
282
+ mockEndpoints.push(endpoint);
283
+ mockEndpointsJson = [config];
284
+ const graphClient = createMockGraphClient([
285
+ { content: [{ type: "text", text: JSON.stringify({ id: "item-2" }) }] }
286
+ ]);
287
+ const server = createMockServer();
288
+ const { registerGraphTools } = await loadModule();
289
+ registerGraphTools(server, graphClient);
290
+ const auditSpy = await spyOnAuditLogger();
291
+ await server.tools.get("get-drive-item").handler({
292
+ driveId: "drive-1"
293
+ });
294
+ const [payload] = auditSpy.mock.calls[0];
295
+ expect(payload).toMatchObject({
296
+ event: "tool.call",
297
+ tool: "get-drive-item",
298
+ status: "success"
299
+ });
300
+ expect(payload).not.toHaveProperty("target_resource");
301
+ auditSpy.mockRestore();
302
+ });
303
+ it("omits SharePoint path parameters from target_resource", async () => {
304
+ const endpoint = makeEndpoint({
305
+ alias: "get-sharepoint-site-by-path",
306
+ path: "/sites/:siteId/getByPath(path=':path')",
307
+ parameters: [
308
+ { name: "siteId", type: "Path", schema: z.string() },
309
+ { name: "path", type: "Path", schema: z.string() }
310
+ ]
311
+ });
312
+ const config = makeConfig({
313
+ toolName: "get-sharepoint-site-by-path",
314
+ pathPattern: "/sites/{site-id}:/{path}",
315
+ scopes: [["Sites.Read.All"], ["Sites.Selected"]]
316
+ });
317
+ mockEndpoints.push(endpoint);
318
+ mockEndpointsJson = [config];
319
+ const graphClient = createMockGraphClient([
320
+ { content: [{ type: "text", text: JSON.stringify({ id: "site-1" }) }] }
321
+ ]);
322
+ const server = createMockServer();
323
+ const { registerGraphTools } = await loadModule();
324
+ registerGraphTools(server, graphClient);
325
+ const auditSpy = await spyOnAuditLogger();
326
+ await server.tools.get("get-sharepoint-site-by-path").handler({
327
+ siteId: "contoso.sharepoint.com",
328
+ path: "/sites/Finance"
329
+ });
330
+ expect(auditSpy).toHaveBeenCalledWith(
331
+ expect.objectContaining({
332
+ event: "tool.call",
333
+ tool: "get-sharepoint-site-by-path",
334
+ status: "success",
335
+ target_resource: {
336
+ type: "site",
337
+ id: "/sites/contoso.sharepoint.com"
338
+ }
339
+ })
340
+ );
341
+ const [payload] = auditSpy.mock.calls[0];
342
+ expect(JSON.stringify(payload)).not.toContain("Finance");
343
+ auditSpy.mockRestore();
344
+ });
345
+ it("omits target_resource for generated broad list/search audit events", async () => {
346
+ const endpoint = makeEndpoint({
347
+ alias: "list-mail-messages",
348
+ path: "/me/messages"
349
+ });
350
+ const config = makeConfig({
351
+ toolName: "list-mail-messages",
352
+ pathPattern: "/me/messages"
353
+ });
354
+ mockEndpoints.push(endpoint);
355
+ mockEndpointsJson = [config];
356
+ const graphClient = createMockGraphClient([
357
+ { content: [{ type: "text", text: JSON.stringify({ value: [] }) }] }
358
+ ]);
359
+ const server = createMockServer();
360
+ const { registerGraphTools } = await loadModule();
361
+ registerGraphTools(server, graphClient);
362
+ const auditSpy = await spyOnAuditLogger();
363
+ await server.tools.get("list-mail-messages").handler({ search: "budget" });
364
+ const [payload] = auditSpy.mock.calls[0];
365
+ expect(payload).toMatchObject({
366
+ event: "tool.call",
367
+ tool: "list-mail-messages",
368
+ status: "success"
369
+ });
370
+ expect(payload).not.toHaveProperty("target_resource");
371
+ auditSpy.mockRestore();
372
+ });
373
+ });
142
374
  describe("fetchAllPages pagination", () => {
143
375
  it("should follow @odata.nextLink and combine results", async () => {
144
376
  const endpoint = makeEndpoint();
@@ -0,0 +1,75 @@
1
+ const CONTROL_PARAM_NAMES = /* @__PURE__ */ new Set([
2
+ "account",
3
+ "confirm",
4
+ "fetchAllPages",
5
+ "includeHeaders",
6
+ "excludeResponse",
7
+ "timezone",
8
+ "expandExtendedProperties"
9
+ ]);
10
+ function toCamelCase(name) {
11
+ return name.replace(/-([a-zA-Z])/g, (_, c) => c.toUpperCase());
12
+ }
13
+ function toKebabCase(name) {
14
+ return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
15
+ }
16
+ function toSnakeCase(name) {
17
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").toLowerCase();
18
+ }
19
+ function valueForPlaceholder(placeholderName, params) {
20
+ const candidates = [placeholderName, toCamelCase(placeholderName), toKebabCase(placeholderName)];
21
+ for (const candidate of candidates) {
22
+ if (CONTROL_PARAM_NAMES.has(candidate)) continue;
23
+ const value = params[candidate];
24
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
25
+ return encodeURIComponent(String(value)).replace(/%3D/g, "=");
26
+ }
27
+ }
28
+ return void 0;
29
+ }
30
+ function resolveGraphPathForAudit(pathPattern, params = {}) {
31
+ if (!pathPattern) return void 0;
32
+ let resolvedPath = pathPattern;
33
+ resolvedPath = resolvedPath.replace(/:([A-Za-z][A-Za-z0-9-]*)/g, (match, name) => {
34
+ return valueForPlaceholder(name, params) ?? match;
35
+ });
36
+ resolvedPath = resolvedPath.replace(/\{([^}]+)\}/g, (match, name) => {
37
+ return valueForPlaceholder(name, params) ?? match;
38
+ });
39
+ if (/:([A-Za-z][A-Za-z0-9-]*)|\{[^}]+\}/.test(resolvedPath)) {
40
+ return void 0;
41
+ }
42
+ return resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
43
+ }
44
+ function idPlaceholderBase(name) {
45
+ const base = name.replace(/[-_]?id\d*$/i, "");
46
+ if (base === name || base.length === 0) return void 0;
47
+ return base;
48
+ }
49
+ function deriveTargetResource(input) {
50
+ const pathPattern = input.pathPattern;
51
+ if (!pathPattern) return void 0;
52
+ const placeholderPattern = /\{([^}]+)\}|:([A-Za-z][A-Za-z0-9-]*)/g;
53
+ let lastTarget;
54
+ for (const match of pathPattern.matchAll(placeholderPattern)) {
55
+ const name = match[1] ?? match[2];
56
+ const base = idPlaceholderBase(name);
57
+ if (!base) continue;
58
+ lastTarget = {
59
+ base,
60
+ end: match.index + match[0].length
61
+ };
62
+ }
63
+ if (!lastTarget) return void 0;
64
+ const targetPattern = pathPattern.slice(0, lastTarget.end);
65
+ const id = resolveGraphPathForAudit(targetPattern, input.params ?? {});
66
+ if (!id) return void 0;
67
+ return {
68
+ type: toSnakeCase(lastTarget.base),
69
+ id
70
+ };
71
+ }
72
+ export {
73
+ deriveTargetResource,
74
+ resolveGraphPathForAudit
75
+ };
@@ -20,6 +20,7 @@ import { TOOL_CATEGORIES } from "./tool-categories.js";
20
20
  import { getRequestTokens } from "./request-context.js";
21
21
  import { parseTeamsUrl } from "./lib/teams-url-parser.js";
22
22
  import { buildBM25Index, scoreQuery, tokenize } from "./lib/bm25.js";
23
+ import { deriveTargetResource } from "./audit-target-resource.js";
23
24
  import { describeToolSchema, describeUtilityToolSchema } from "./lib/tool-schema.js";
24
25
  import {
25
26
  TOP_UNSUPPORTED_DELTA_TOOLS,
@@ -598,6 +599,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
598
599
  const startTime = Date.now();
599
600
  const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
600
601
  const httpMethod = tool.method.toUpperCase();
602
+ let targetResource;
601
603
  try {
602
604
  const accountParam = params.account;
603
605
  const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
@@ -815,6 +817,10 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
815
817
  if (accountAccessToken) {
816
818
  options.accessToken = accountAccessToken;
817
819
  }
820
+ targetResource = deriveTargetResource({
821
+ pathPattern: config?.pathPattern ?? tool.path,
822
+ params
823
+ });
818
824
  const { accessToken: _redacted, ...safeOptions } = options;
819
825
  logger.info(
820
826
  `Making graph request to ${path2} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
@@ -915,7 +921,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
915
921
  tool: tool.alias,
916
922
  http_method: httpMethod,
917
923
  status: response.isError ? "error" : "success",
918
- duration_ms: Date.now() - startTime
924
+ duration_ms: Date.now() - startTime,
925
+ ...targetResource ? { target_resource: targetResource } : {}
919
926
  });
920
927
  return {
921
928
  content,
@@ -933,6 +940,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
933
940
  http_method: httpMethod,
934
941
  status: "error",
935
942
  duration_ms: Date.now() - startTime,
943
+ ...targetResource ? { target_resource: targetResource } : {},
936
944
  error_type: err?.name || "Error",
937
945
  error_code: err?.status ?? err?.code
938
946
  });
@@ -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 stdout (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, error_type?, error_code? }`. The schema is intentionally narrow — tool parameters 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. 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 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? }`. 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, tool parameters, 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. 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.135.0",
3
+ "version": "0.136.0",
4
4
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",