@softeria/ms-365-mcp-server 0.113.0 → 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/graph-client.js +16 -6
- package/dist/lib/graph-resilience.js +208 -0
- package/docs/deployment.md +1 -0
- package/package.json +1 -1
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") {
|
|
@@ -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
|
@@ -209,6 +209,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
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
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`
|
|
212
213
|
|
|
213
214
|
## Exposed Endpoints
|
|
214
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",
|