@softeria/ms-365-mcp-server 0.112.2 → 0.114.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
+ };
@@ -2,6 +2,11 @@ import logger from "./logger.js";
2
2
  import { encode as toonEncode } from "@toon-format/toon";
3
3
  import { getCloudEndpoints } from "./cloud-config.js";
4
4
  import { getRequestTokens } from "./request-context.js";
5
+ import {
6
+ fetchWithResilience,
7
+ getSharedBreaker,
8
+ loadResilienceConfig
9
+ } from "./lib/graph-resilience.js";
5
10
  function isBinaryContentType(contentType) {
6
11
  if (!contentType) return false;
7
12
  const lower = contentType.toLowerCase().split(";")[0].trim();
@@ -102,12 +107,17 @@ class GraphClient {
102
107
  "Content-Type": "application/json",
103
108
  ...options.headers
104
109
  };
105
- return fetch(url, {
106
- method: options.method || "GET",
107
- headers,
108
- // Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
109
- body: options.body
110
- });
110
+ return fetchWithResilience(
111
+ url,
112
+ {
113
+ method: options.method || "GET",
114
+ headers,
115
+ // Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
116
+ body: options.body
117
+ },
118
+ loadResilienceConfig(),
119
+ getSharedBreaker()
120
+ );
111
121
  }
112
122
  serializeData(data, outputFormat, pretty = false) {
113
123
  if (outputFormat === "toon") {
@@ -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
  {
@@ -0,0 +1,208 @@
1
+ import logger from "../logger.js";
2
+ function loadResilienceConfig() {
3
+ const intEnv = (name, fallback) => {
4
+ const raw = process.env[name];
5
+ if (raw === void 0 || raw === "") return fallback;
6
+ const n = Number.parseInt(raw, 10);
7
+ if (!Number.isFinite(n) || n < 0) {
8
+ logger.warn(`Ignoring invalid ${name}=${JSON.stringify(raw)} (use a non-negative integer)`);
9
+ return fallback;
10
+ }
11
+ return n;
12
+ };
13
+ return {
14
+ maxRetries: intEnv("MS365_MCP_GRAPH_MAX_RETRIES", 3),
15
+ baseBackoffMs: intEnv("MS365_MCP_GRAPH_BASE_BACKOFF_MS", 200),
16
+ maxBackoffMs: intEnv("MS365_MCP_GRAPH_MAX_BACKOFF_MS", 5e3),
17
+ fetchTimeoutMs: intEnv("MS365_MCP_GRAPH_TIMEOUT_MS", 1e5),
18
+ circuitFailureThreshold: intEnv("MS365_MCP_GRAPH_CIRCUIT_THRESHOLD", 5),
19
+ circuitCooldownMs: intEnv("MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS", 3e4),
20
+ circuitDisabled: process.env.MS365_MCP_GRAPH_CIRCUIT_DISABLED === "true" || process.env.MS365_MCP_GRAPH_CIRCUIT_DISABLED === "1"
21
+ };
22
+ }
23
+ class CircuitOpenError extends Error {
24
+ constructor(cooldownMs) {
25
+ super(
26
+ `Graph circuit breaker is open (cooldown ${cooldownMs} ms). Upstream has failed repeatedly; refusing to flood it.`
27
+ );
28
+ this.code = "circuit_open";
29
+ this.name = "CircuitOpenError";
30
+ this.cooldownMs = cooldownMs;
31
+ }
32
+ }
33
+ class CircuitBreaker {
34
+ constructor(threshold, cooldownMs, disabled, now = () => Date.now()) {
35
+ this.threshold = threshold;
36
+ this.cooldownMs = cooldownMs;
37
+ this.disabled = disabled;
38
+ this.now = now;
39
+ this.failures = 0;
40
+ this.openedAt = null;
41
+ }
42
+ /**
43
+ * @returns the time-remaining (in ms) before the circuit can be probed,
44
+ * or `null` if the circuit is closed and the call should proceed.
45
+ */
46
+ checkBeforeRequest() {
47
+ if (this.disabled) return null;
48
+ if (this.openedAt === null) return null;
49
+ const elapsed = this.now() - this.openedAt;
50
+ if (elapsed >= this.cooldownMs) {
51
+ return null;
52
+ }
53
+ return this.cooldownMs - elapsed;
54
+ }
55
+ recordSuccess() {
56
+ if (this.failures !== 0 || this.openedAt !== null) {
57
+ logger.info("Graph circuit: success \u2014 closing breaker");
58
+ }
59
+ this.failures = 0;
60
+ this.openedAt = null;
61
+ }
62
+ recordFailure() {
63
+ if (this.disabled) return;
64
+ this.failures += 1;
65
+ if (this.failures >= this.threshold && this.openedAt === null) {
66
+ this.openedAt = this.now();
67
+ logger.warn(
68
+ `Graph circuit: ${this.failures} consecutive failures \u2014 opening breaker for ${this.cooldownMs} ms`
69
+ );
70
+ } else if (this.openedAt !== null) {
71
+ this.openedAt = this.now();
72
+ logger.warn("Graph circuit: probe failed \u2014 extending cooldown");
73
+ }
74
+ }
75
+ /** Exposed for tests / metrics. */
76
+ getState() {
77
+ return {
78
+ failures: this.failures,
79
+ openedAt: this.openedAt,
80
+ open: this.checkBeforeRequest() !== null
81
+ };
82
+ }
83
+ }
84
+ function parseRetryAfterMs(header) {
85
+ if (!header) return null;
86
+ const trimmed = header.trim();
87
+ if (trimmed === "") return null;
88
+ const asInt = Number.parseInt(trimmed, 10);
89
+ if (Number.isFinite(asInt) && asInt >= 0 && String(asInt) === trimmed) {
90
+ return Math.min(asInt * 1e3, 6e4);
91
+ }
92
+ if (!/[-/:,]| GMT$/i.test(trimmed) && !/\s+\d/.test(trimmed)) {
93
+ return null;
94
+ }
95
+ const dateMs = Date.parse(trimmed);
96
+ if (Number.isFinite(dateMs)) {
97
+ const delta = dateMs - Date.now();
98
+ if (delta <= 0) return 0;
99
+ return Math.min(delta, 6e4);
100
+ }
101
+ return null;
102
+ }
103
+ function backoffDelayMs(attempt, baseMs, maxMs, rand = Math.random) {
104
+ const exp = Math.min(maxMs, baseMs * 2 ** attempt);
105
+ return Math.floor(rand() * exp);
106
+ }
107
+ function isRetriableStatus(status) {
108
+ return status === 429 || status === 503 || status === 504;
109
+ }
110
+ function isMethodIdempotent(method) {
111
+ const m = method.toUpperCase();
112
+ return m === "GET" || m === "HEAD" || m === "PUT" || m === "DELETE" || m === "OPTIONS" || m === "TRACE";
113
+ }
114
+ function isAbortError(err) {
115
+ return typeof err === "object" && err !== null && "name" in err && err.name === "AbortError";
116
+ }
117
+ async function fetchWithResilience(url, init, config, breaker, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
118
+ const remainingCooldown = breaker.checkBeforeRequest();
119
+ if (remainingCooldown !== null) {
120
+ throw new CircuitOpenError(remainingCooldown);
121
+ }
122
+ const method = (init?.method ?? "GET").toString().toUpperCase();
123
+ const methodIsIdempotent = isMethodIdempotent(method);
124
+ let attempt = 0;
125
+ while (true) {
126
+ const controller = new AbortController();
127
+ const timer = setTimeout(() => controller.abort(), config.fetchTimeoutMs);
128
+ let response = null;
129
+ let networkError = null;
130
+ try {
131
+ response = await fetch(url, { ...init, signal: controller.signal });
132
+ } catch (err) {
133
+ networkError = err;
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ if (response !== null && !isRetriableStatus(response.status)) {
138
+ breaker.recordSuccess();
139
+ return response;
140
+ }
141
+ const is429 = response !== null && response.status === 429;
142
+ const retryAllowedByMethod = methodIsIdempotent || is429;
143
+ const canRetry = attempt < config.maxRetries && retryAllowedByMethod;
144
+ if (!canRetry) {
145
+ breaker.recordFailure();
146
+ if (response !== null) {
147
+ if (!retryAllowedByMethod && attempt === 0) {
148
+ logger.warn(
149
+ `Graph ${method} ${response.status}: not retried (non-idempotent method, side-effect may have landed)`
150
+ );
151
+ }
152
+ return response;
153
+ }
154
+ if (!retryAllowedByMethod && attempt === 0) {
155
+ logger.warn(
156
+ `Graph ${method} network error: not retried (non-idempotent method, side-effect may have landed)`
157
+ );
158
+ }
159
+ throw networkError ?? new Error("Graph fetch failed (unknown error)");
160
+ }
161
+ let delayMs;
162
+ if (response !== null && response.status === 429) {
163
+ const retryAfter = parseRetryAfterMs(response.headers.get("retry-after"));
164
+ delayMs = retryAfter !== null ? retryAfter : backoffDelayMs(attempt, config.baseBackoffMs, config.maxBackoffMs);
165
+ } else {
166
+ delayMs = backoffDelayMs(attempt, config.baseBackoffMs, config.maxBackoffMs);
167
+ }
168
+ const reason = response !== null ? `HTTP ${response.status}` : isAbortError(networkError) ? `timeout (${config.fetchTimeoutMs} ms)` : `network error: ${networkError?.message ?? "unknown"}`;
169
+ logger.warn(
170
+ `Graph retry ${attempt + 1}/${config.maxRetries} after ${reason} \u2014 sleeping ${delayMs} ms`
171
+ );
172
+ if (response !== null) {
173
+ try {
174
+ await response.arrayBuffer();
175
+ } catch {
176
+ }
177
+ }
178
+ breaker.recordFailure();
179
+ attempt += 1;
180
+ await sleep(delayMs);
181
+ }
182
+ }
183
+ let _sharedBreaker = null;
184
+ function getSharedBreaker() {
185
+ if (_sharedBreaker === null) {
186
+ const cfg = loadResilienceConfig();
187
+ _sharedBreaker = new CircuitBreaker(
188
+ cfg.circuitFailureThreshold,
189
+ cfg.circuitCooldownMs,
190
+ cfg.circuitDisabled
191
+ );
192
+ }
193
+ return _sharedBreaker;
194
+ }
195
+ function __resetSharedBreakerForTests() {
196
+ _sharedBreaker = null;
197
+ }
198
+ export {
199
+ CircuitBreaker,
200
+ CircuitOpenError,
201
+ __resetSharedBreakerForTests,
202
+ backoffDelayMs,
203
+ fetchWithResilience,
204
+ getSharedBreaker,
205
+ isMethodIdempotent,
206
+ loadResilienceConfig,
207
+ parseRetryAfterMs
208
+ };
@@ -208,6 +208,8 @@ 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`
212
+ - **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`
211
213
 
212
214
  ## Exposed Endpoints
213
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
- "version": "0.112.2",
3
+ "version": "0.114.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",