@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.
- package/dist/audit-log.js +72 -0
- package/dist/graph-client.js +16 -6
- package/dist/graph-tools.js +27 -0
- package/dist/lib/graph-resilience.js +208 -0
- package/docs/deployment.md +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/dist/graph-client.js
CHANGED
|
@@ -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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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") {
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
+
};
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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",
|