@softeria/ms-365-mcp-server 0.112.1 → 0.113.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.
@@ -0,0 +1,72 @@
1
+ import winston from "winston";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ const logsDir = process.env.MS365_MCP_LOG_DIR || path.join(os.homedir(), ".ms-365-mcp-server", "logs");
6
+ const FILE_MODE = 384;
7
+ const auditLogPath = path.join(logsDir, "audit.log");
8
+ try {
9
+ if (!fs.existsSync(logsDir)) {
10
+ fs.mkdirSync(logsDir, { recursive: true, mode: 448 });
11
+ }
12
+ if (fs.existsSync(auditLogPath)) {
13
+ fs.chmodSync(auditLogPath, FILE_MODE);
14
+ }
15
+ } catch {
16
+ }
17
+ const auditLogger = winston.createLogger({
18
+ level: "info",
19
+ format: winston.format.combine(
20
+ winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
21
+ winston.format.json()
22
+ ),
23
+ defaultMeta: {
24
+ service: "ms-365-mcp-server",
25
+ stream: "audit"
26
+ },
27
+ transports: [
28
+ new winston.transports.Console({
29
+ // Route audit events to stderr so they don't collide with JSON-RPC on
30
+ // stdout when this server runs in stdio mode. Container platforms
31
+ // (Container Apps, App Service, Docker) capture both stdout and stderr
32
+ // and forward to Log Analytics, so the production audit sink is
33
+ // unaffected. Vitest sets `VITEST=true`; staying silent there avoids
34
+ // polluting unrelated tests that exercise the real graph-tools module.
35
+ stderrLevels: ["info"],
36
+ silent: process.env.SILENT === "true" || process.env.SILENT === "1" || process.env.VITEST === "true"
37
+ }),
38
+ new winston.transports.File({
39
+ filename: auditLogPath,
40
+ options: { flags: "a", mode: FILE_MODE }
41
+ })
42
+ ]
43
+ });
44
+ function isAuditLogEnabled() {
45
+ return process.env.MS365_MCP_AUDIT_LOG !== "false";
46
+ }
47
+ function auditLog(evt) {
48
+ if (!isAuditLogEnabled()) return;
49
+ auditLogger.info(evt);
50
+ }
51
+ function getUserIdentityForAudit(token) {
52
+ if (!token) return void 0;
53
+ try {
54
+ const parts = token.split(".");
55
+ if (parts.length < 2) return void 0;
56
+ let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
57
+ const padNeeded = (4 - b64.length % 4) % 4;
58
+ b64 = b64 + "=".repeat(padNeeded);
59
+ const payload = JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
60
+ const candidate = payload.upn || payload.preferred_username || payload.email || payload.sub;
61
+ return typeof candidate === "string" ? candidate : void 0;
62
+ } catch {
63
+ return void 0;
64
+ }
65
+ }
66
+ const __testing = { auditLogger, auditLogPath };
67
+ export {
68
+ __testing,
69
+ auditLog,
70
+ getUserIdentityForAudit,
71
+ isAuditLogEnabled
72
+ };
@@ -0,0 +1,41 @@
1
+ function dumpError(reason, depth = 0) {
2
+ if (depth > 5) {
3
+ return { truncated: true };
4
+ }
5
+ if (reason instanceof Error) {
6
+ const dump = {
7
+ name: reason.name,
8
+ constructor: reason.constructor?.name,
9
+ message: reason.message,
10
+ stack: reason.stack
11
+ };
12
+ const properties = {};
13
+ for (const key of Object.getOwnPropertyNames(reason)) {
14
+ if (key === "name" || key === "message" || key === "stack" || key === "cause") continue;
15
+ properties[key] = reason[key];
16
+ }
17
+ if (Object.keys(properties).length > 0) {
18
+ dump.properties = properties;
19
+ }
20
+ if ("cause" in reason && reason.cause !== void 0) {
21
+ dump.cause = dumpError(reason.cause, depth + 1);
22
+ }
23
+ return dump;
24
+ }
25
+ return { type: typeof reason, value: reason };
26
+ }
27
+ function getActiveResources() {
28
+ const fn = process.getActiveResourcesInfo;
29
+ if (typeof fn !== "function") {
30
+ return "unavailable (node < 17.3)";
31
+ }
32
+ try {
33
+ return fn.call(process);
34
+ } catch (err) {
35
+ return `error: ${err.message}`;
36
+ }
37
+ }
38
+ export {
39
+ dumpError,
40
+ getActiveResources
41
+ };
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from "crypto";
1
2
  import logger from "./logger.js";
3
+ import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
2
4
  import {
3
5
  getEndpointRequiredScopes,
4
6
  getMissingAllowedScopes,
@@ -151,6 +153,10 @@ function registerUtilityToolWithMcp(server, utility, ctx) {
151
153
  }
152
154
  async function executeGraphTool(tool, config, graphClient, params, authManager) {
153
155
  logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
156
+ const requestId = randomUUID();
157
+ const startTime = Date.now();
158
+ const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
159
+ const httpMethod = tool.method.toUpperCase();
154
160
  try {
155
161
  let accountAccessToken;
156
162
  if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
@@ -394,13 +400,34 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
394
400
  type: "text",
395
401
  text: item.text
396
402
  }));
403
+ auditLog({
404
+ event: "tool.call",
405
+ request_id: requestId,
406
+ user_principal_name: upn,
407
+ tool: tool.alias,
408
+ http_method: httpMethod,
409
+ status: response.isError ? "error" : "success",
410
+ duration_ms: Date.now() - startTime
411
+ });
397
412
  return {
398
413
  content,
399
414
  _meta: response._meta,
400
415
  isError: response.isError
401
416
  };
402
417
  } catch (error) {
418
+ const err = error;
403
419
  logger.error(`Error in tool ${tool.alias}: ${error.message}`);
420
+ auditLog({
421
+ event: "tool.call",
422
+ request_id: requestId,
423
+ user_principal_name: upn,
424
+ tool: tool.alias,
425
+ http_method: httpMethod,
426
+ status: "error",
427
+ duration_ms: Date.now() - startTime,
428
+ error_type: err?.name || "Error",
429
+ error_code: err?.status ?? err?.code
430
+ });
404
431
  return {
405
432
  content: [
406
433
  {
package/dist/index.js CHANGED
@@ -10,7 +10,27 @@ import {
10
10
  shouldUseLocalAuthStorage
11
11
  } from "./startup-pinning.js";
12
12
  import { createTokenCacheStorage } from "./token-cache-storage.js";
13
+ import { dumpError, getActiveResources } from "./crash-logging.js";
13
14
  import { version } from "./version.js";
15
+ process.on("unhandledRejection", (reason) => {
16
+ const dump = {
17
+ kind: "unhandledRejection",
18
+ reason: dumpError(reason),
19
+ activeResources: getActiveResources()
20
+ };
21
+ console.error("[ms365-mcp] unhandledRejection", JSON.stringify(dump));
22
+ logger.error("unhandledRejection", dump);
23
+ });
24
+ process.on("uncaughtException", (err, origin) => {
25
+ const dump = {
26
+ kind: "uncaughtException",
27
+ origin,
28
+ error: dumpError(err),
29
+ activeResources: getActiveResources()
30
+ };
31
+ console.error("[ms365-mcp] uncaughtException", JSON.stringify(dump));
32
+ logger.error("uncaughtException", dump);
33
+ });
14
34
  async function main() {
15
35
  try {
16
36
  const args = parseArgs();
package/dist/server.js CHANGED
@@ -25,6 +25,7 @@ import { isAllowedRedirectUri, parseAllowlist } from "./lib/redirect-uri-validat
25
25
  import { getSecrets } from "./secrets.js";
26
26
  import { getCloudEndpoints } from "./cloud-config.js";
27
27
  import { requestContext } from "./request-context.js";
28
+ import { dumpError } from "./crash-logging.js";
28
29
  import crypto from "node:crypto";
29
30
  import OboClient from "./obo-client.js";
30
31
  function parseHttpOption(httpOption) {
@@ -537,6 +538,9 @@ class MicrosoftGraphServer {
537
538
  }
538
539
  } else {
539
540
  const transport = new StdioServerTransport();
541
+ transport.onerror = (error) => {
542
+ logger.error("Stdio transport error", { error: dumpError(error) });
543
+ };
540
544
  await this.server.connect(transport);
541
545
  logger.info("Server connected to stdio transport");
542
546
  }
@@ -208,6 +208,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
208
208
  - **Read-only mode**: use `--read-only` to disable all write operations (send, delete, update, create)
209
209
  - **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
210
210
  - **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
211
+ - **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`
211
212
 
212
213
  ## Exposed Endpoints
213
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.112.1",
3
+ "version": "0.113.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",