@softeria/ms-365-mcp-server 0.113.0 → 0.114.1

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.
@@ -489,12 +489,6 @@
489
489
  "toolName": "get-drive-root-item",
490
490
  "scopes": ["Files.Read"]
491
491
  },
492
- {
493
- "pathPattern": "/drives/{drive-id}/root",
494
- "method": "get",
495
- "toolName": "get-drive-root-item",
496
- "scopes": ["Files.Read"]
497
- },
498
492
  {
499
493
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/children",
500
494
  "method": "get",
@@ -365,6 +365,57 @@ const microsoft_graph_teamsTab = z.object({
365
365
  webUrl: z.string().describe("Deep link URL of the tab instance. Read-only.").nullish(),
366
366
  teamsApp: microsoft_graph_teamsApp.optional()
367
367
  }).passthrough();
368
+ const microsoft_graph_targetedChatMessage = z.object({
369
+ id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
370
+ createdDateTime: z.string().regex(
371
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
372
+ ).datetime({ offset: true }).describe("Timestamp of when the chat message was created.").nullish(),
373
+ lastModifiedDateTime: z.string().regex(
374
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
375
+ ).datetime({ offset: true }).describe(
376
+ "Read only. Timestamp when the chat message is created (initial setting) or modified, including when a reaction is added or removed."
377
+ ).nullish(),
378
+ body: microsoft_graph_itemBody.optional(),
379
+ subject: z.string().describe("The subject of the chat message, in plaintext.").nullish(),
380
+ attachments: z.array(microsoft_graph_chatMessageAttachment).describe("References to attached objects like files, tabs, meetings etc.").optional(),
381
+ importance: microsoft_graph_chatMessageImportance.optional(),
382
+ from: microsoft_graph_chatMessageFromIdentitySet.optional(),
383
+ channelIdentity: microsoft_graph_channelIdentity.optional(),
384
+ chatId: z.string().describe("If the message was sent in a chat, represents the identity of the chat.").nullish(),
385
+ deletedDateTime: z.string().regex(
386
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
387
+ ).datetime({ offset: true }).describe(
388
+ "Read only. Timestamp at which the chat message was deleted, or null if not deleted."
389
+ ).nullish(),
390
+ etag: z.string().describe("Read-only. Version number of the chat message.").nullish(),
391
+ eventDetail: microsoft_graph_eventMessageDetail.optional(),
392
+ lastEditedDateTime: z.string().regex(
393
+ /^[0-9]{4,}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]{1,12})?(Z|[+-][0-9][0-9]:[0-9][0-9])$/
394
+ ).datetime({ offset: true }).describe(
395
+ "Read only. Timestamp when edits to the chat message were made. Triggers an 'Edited' flag in the Teams UI. If no edits are made the value is null."
396
+ ).nullish(),
397
+ locale: z.string().describe("Locale of the chat message set by the client. Always set to en-us.").optional(),
398
+ mentions: z.array(microsoft_graph_chatMessageMention).describe(
399
+ "List of entities mentioned in the chat message. Supported entities are: user, bot, team, channel, chat, and tag."
400
+ ).optional(),
401
+ messageHistory: z.array(microsoft_graph_chatMessageHistoryItem).describe(
402
+ "List of activity history of a message item, including modification time and actions, such as reactionAdded, reactionRemoved, or reaction changes, on the message."
403
+ ).optional(),
404
+ messageType: microsoft_graph_chatMessageType.optional(),
405
+ policyViolation: microsoft_graph_chatMessagePolicyViolation.optional(),
406
+ reactions: z.array(microsoft_graph_chatMessageReaction).describe("Reactions for this chat message (for example, Like).").optional(),
407
+ replyToId: z.string().describe(
408
+ "Read-only. ID of the parent chat message or root chat message of the thread. (Only applies to chat messages in channels, not chats.)"
409
+ ).nullish(),
410
+ summary: z.string().describe(
411
+ "Summary text of the chat message that could be used for push notifications and summary views or fall back views. Only applies to channel chat messages, not chat messages in a chat."
412
+ ).nullish(),
413
+ webUrl: z.string().describe("Read-only. Link to the message in Microsoft Teams.").nullish(),
414
+ hostedContents: z.array(microsoft_graph_chatMessageHostedContent).describe(
415
+ "Content in a message hosted by Microsoft Teams - for example, images or code snippets."
416
+ ).optional(),
417
+ replies: z.array(microsoft_graph_chatMessage).describe("Replies for a specified message. Supports $expand for channel messages.").optional()
418
+ }).passthrough().passthrough();
368
419
  const microsoft_graph_chat = z.object({
369
420
  id: z.string().describe("The unique identifier for an entity. Read-only.").optional(),
370
421
  chatType: microsoft_graph_chatType.optional(),
@@ -396,7 +447,8 @@ const microsoft_graph_chat = z.object({
396
447
  messages: z.array(microsoft_graph_chatMessage).describe("A collection of all the messages in the chat. Nullable.").optional(),
397
448
  permissionGrants: z.array(microsoft_graph_resourceSpecificPermissionGrant).describe("A collection of permissions granted to apps for the chat.").optional(),
398
449
  pinnedMessages: z.array(microsoft_graph_pinnedChatMessageInfo).describe("A collection of all the pinned messages in the chat. Nullable.").optional(),
399
- tabs: z.array(microsoft_graph_teamsTab).describe("A collection of all the tabs in the chat. Nullable.").optional()
450
+ tabs: z.array(microsoft_graph_teamsTab).describe("A collection of all the tabs in the chat. Nullable.").optional(),
451
+ targetedMessages: z.array(microsoft_graph_targetedChatMessage).optional()
400
452
  }).passthrough();
401
453
  const microsoft_graph_ODataErrors_ErrorDetails = z.object({ code: z.string(), message: z.string(), target: z.string().nullish() }).passthrough();
402
454
  const microsoft_graph_ODataErrors_InnerError = z.object({
@@ -4666,6 +4718,7 @@ const schemas = {
4666
4718
  microsoft_graph_pinnedChatMessageInfo,
4667
4719
  microsoft_graph_teamsTabConfiguration,
4668
4720
  microsoft_graph_teamsTab,
4721
+ microsoft_graph_targetedChatMessage,
4669
4722
  microsoft_graph_chat,
4670
4723
  microsoft_graph_ODataErrors_ErrorDetails,
4671
4724
  microsoft_graph_ODataErrors_InnerError,
@@ -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") {
@@ -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
+ };
@@ -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.113.0",
3
+ "version": "0.114.1",
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",
@@ -60,7 +60,7 @@
60
60
  "@types/node": "^22.15.15",
61
61
  "@typescript-eslint/eslint-plugin": "^8.38.0",
62
62
  "@typescript-eslint/parser": "^8.38.0",
63
- "@vitest/coverage-v8": "^3.2.4",
63
+ "@vitest/coverage-v8": "^4.1.8",
64
64
  "eslint": "^9.31.0",
65
65
  "globals": "^16.3.0",
66
66
  "patch-package": "^8.0.1",
@@ -69,7 +69,7 @@
69
69
  "tsup": "^8.5.0",
70
70
  "tsx": "^4.19.4",
71
71
  "typescript": "^5.8.3",
72
- "vitest": "^3.1.1"
72
+ "vitest": "^4.1.8"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=18"
@@ -489,12 +489,6 @@
489
489
  "toolName": "get-drive-root-item",
490
490
  "scopes": ["Files.Read"]
491
491
  },
492
- {
493
- "pathPattern": "/drives/{drive-id}/root",
494
- "method": "get",
495
- "toolName": "get-drive-root-item",
496
- "scopes": ["Files.Read"]
497
- },
498
492
  {
499
493
  "pathPattern": "/drives/{drive-id}/items/{driveItem-id}/children",
500
494
  "method": "get",