@softeria/ms-365-mcp-server 0.143.0 → 0.145.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.
@@ -34,7 +34,6 @@ function registerAuthTools(server, authManager) {
34
34
  type: "text",
35
35
  text: JSON.stringify({
36
36
  status: "Login successful",
37
- message: "Browser authentication completed successfully.",
38
37
  ...loginResult
39
38
  })
40
39
  }
@@ -31,6 +31,62 @@ function isBinaryContentType(contentType) {
31
31
  }
32
32
  return false;
33
33
  }
34
+ class GraphApiError extends Error {
35
+ constructor(message, httpStatus, graphErrorCode) {
36
+ super(message);
37
+ this.name = "GraphApiError";
38
+ this.httpStatus = httpStatus;
39
+ this.graphErrorCode = graphErrorCode;
40
+ }
41
+ }
42
+ function extractGraphErrorCode(errorText) {
43
+ try {
44
+ const body = JSON.parse(errorText);
45
+ const code = body.error?.code ?? body.code;
46
+ return typeof code === "string" ? code : void 0;
47
+ } catch {
48
+ return void 0;
49
+ }
50
+ }
51
+ function definedMetadata(metadata) {
52
+ return Object.fromEntries(
53
+ Object.entries(metadata).filter(([, value]) => value !== void 0)
54
+ );
55
+ }
56
+ function isRecord(value) {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+ function incrementCount(counts, key) {
60
+ counts[key] = (counts[key] ?? 0) + 1;
61
+ }
62
+ function extractGraphErrorCodeFromBody(body) {
63
+ if (!isRecord(body)) return void 0;
64
+ const error = body.error;
65
+ const code = isRecord(error) ? error.code : body.code;
66
+ return typeof code === "string" ? code : void 0;
67
+ }
68
+ function extractBatchMetadata(data) {
69
+ if (!isRecord(data) || !Array.isArray(data.responses)) return {};
70
+ const httpStatusCounts = {};
71
+ const errorCodeCounts = {};
72
+ for (const item of data.responses) {
73
+ if (!isRecord(item)) continue;
74
+ const status = item.status;
75
+ if (typeof status !== "number" || !Number.isInteger(status)) continue;
76
+ incrementCount(httpStatusCounts, String(status));
77
+ if (status >= 400) {
78
+ const errorCode = extractGraphErrorCodeFromBody(item.body);
79
+ if (errorCode) {
80
+ incrementCount(errorCodeCounts, errorCode);
81
+ }
82
+ }
83
+ }
84
+ return {
85
+ graph_batch_subrequest_count: data.responses.length,
86
+ ...Object.keys(httpStatusCounts).length > 0 ? { graph_batch_http_status_counts: httpStatusCounts } : {},
87
+ ...Object.keys(errorCodeCounts).length > 0 ? { graph_batch_error_code_counts: errorCodeCounts } : {}
88
+ };
89
+ }
34
90
  class GraphClient {
35
91
  constructor(authManager, secrets, outputFormat = "json") {
36
92
  this.outputFormat = "json";
@@ -39,6 +95,9 @@ class GraphClient {
39
95
  this.outputFormat = outputFormat;
40
96
  }
41
97
  async makeRequest(endpoint, options = {}) {
98
+ return (await this.makeRequestWithMetadata(endpoint, options)).data;
99
+ }
100
+ async makeRequestWithMetadata(endpoint, options = {}) {
42
101
  const contextTokens = getRequestTokens();
43
102
  const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
44
103
  if (!accessToken) {
@@ -47,23 +106,14 @@ class GraphClient {
47
106
  try {
48
107
  const response = await this.performRequest(endpoint, accessToken, options);
49
108
  if (response.status === 403) {
50
- const errorText = await response.text();
51
- if (errorText.includes("scope") || errorText.includes("permission")) {
52
- throw new Error(
53
- `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
54
- );
55
- }
56
- throw new Error(
57
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
58
- );
109
+ throw await this.createGraphApiError(response);
59
110
  }
60
111
  if (!response.ok) {
61
- throw new Error(
62
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
63
- );
112
+ throw await this.createGraphApiError(response);
64
113
  }
65
114
  const contentTypeHeader = response.headers?.get?.("content-type") || "";
66
115
  const isBinaryResponse = isBinaryContentType(contentTypeHeader);
116
+ let metadata = { http_status: response.status };
67
117
  let result;
68
118
  if (isBinaryResponse) {
69
119
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -88,16 +138,22 @@ class GraphClient {
88
138
  }
89
139
  }
90
140
  }
141
+ if (endpoint === "/$batch") {
142
+ metadata = { ...metadata, ...extractBatchMetadata(result) };
143
+ }
91
144
  if (options.includeHeaders) {
92
145
  const etag = response.headers.get("ETag") || response.headers.get("etag");
93
146
  if (result && typeof result === "object" && !Array.isArray(result)) {
94
147
  return {
95
- ...result,
96
- _etag: etag || "no-etag-found"
148
+ data: {
149
+ ...result,
150
+ _etag: etag || "no-etag-found"
151
+ },
152
+ metadata
97
153
  };
98
154
  }
99
155
  }
100
- return result;
156
+ return { data: result, metadata };
101
157
  } catch (error) {
102
158
  logger.error("Microsoft Graph API request failed:", error);
103
159
  throw error;
@@ -121,20 +177,10 @@ class GraphClient {
121
177
  }
122
178
  const response = await this.performRequest(endpoint, accessToken, options);
123
179
  if (response.status === 403) {
124
- const errorText = await response.text();
125
- if (errorText.includes("scope") || errorText.includes("permission")) {
126
- throw new Error(
127
- `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
128
- );
129
- }
130
- throw new Error(
131
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
132
- );
180
+ throw await this.createGraphApiError(response);
133
181
  }
134
182
  if (!response.ok) {
135
- throw new Error(
136
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
137
- );
183
+ throw await this.createGraphApiError(response);
138
184
  }
139
185
  if (!response.body) {
140
186
  throw new Error("Microsoft Graph returned an empty response body");
@@ -150,7 +196,8 @@ class GraphClient {
150
196
  }
151
197
  return {
152
198
  contentType: response.headers.get("content-type") || "application/octet-stream",
153
- contentLength
199
+ contentLength,
200
+ httpStatus: response.status
154
201
  };
155
202
  } catch (error) {
156
203
  logger.error("Microsoft Graph file download failed:", error);
@@ -162,6 +209,23 @@ class GraphClient {
162
209
  }
163
210
  }
164
211
  }
212
+ async createGraphApiError(response) {
213
+ const errorText = await response.text();
214
+ const graphErrorCode = extractGraphErrorCode(errorText);
215
+ const isPermissionOrScopeError = errorText.includes("scope") || errorText.includes("permission");
216
+ if (response.status === 403 && isPermissionOrScopeError) {
217
+ return new GraphApiError(
218
+ `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`,
219
+ response.status,
220
+ graphErrorCode
221
+ );
222
+ }
223
+ return new GraphApiError(
224
+ `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`,
225
+ response.status,
226
+ graphErrorCode
227
+ );
228
+ }
165
229
  async performRequest(endpoint, accessToken, options) {
166
230
  const cloudEndpoints = getCloudEndpoints(this.secrets.cloudType);
167
231
  const apiVersion = options.apiVersion || "v1.0";
@@ -211,22 +275,36 @@ class GraphClient {
211
275
  logger.info(
212
276
  `Calling ${endpoint} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
213
277
  );
214
- const result = await this.makeRequest(endpoint, options);
278
+ const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
215
279
  const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
216
- return this.formatJsonResponse(
217
- result,
218
- options.rawResponse,
219
- options.excludeResponse,
220
- outputFormat
280
+ return this.withResponseMetadata(
281
+ this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
282
+ metadata
221
283
  );
222
284
  } catch (error) {
223
285
  logger.error(`Error in Graph API request: ${error}`);
286
+ const metadata = error instanceof GraphApiError ? definedMetadata({
287
+ http_status: error.httpStatus,
288
+ error_code: error.graphErrorCode
289
+ }) : void 0;
224
290
  return {
225
291
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
226
- isError: true
292
+ isError: true,
293
+ ...metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
227
294
  };
228
295
  }
229
296
  }
297
+ withResponseMetadata(response, metadata) {
298
+ const cleanMetadata = definedMetadata(metadata);
299
+ if (Object.keys(cleanMetadata).length === 0) return response;
300
+ return {
301
+ ...response,
302
+ _meta: {
303
+ ...response._meta,
304
+ ...cleanMetadata
305
+ }
306
+ };
307
+ }
230
308
  formatJsonResponse(data, rawResponse = false, excludeResponse = false, outputFormat = this.outputFormat) {
231
309
  if (excludeResponse) {
232
310
  return {
@@ -302,6 +380,7 @@ class GraphClient {
302
380
  }
303
381
  var graph_client_default = GraphClient;
304
382
  export {
383
+ GraphApiError,
305
384
  graph_client_default as default,
306
385
  isBinaryContentType
307
386
  };
@@ -1,3 +1,4 @@
1
+ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1
2
  import { randomUUID } from "crypto";
2
3
  import logger from "./logger.js";
3
4
  import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
@@ -76,11 +77,172 @@ const DEFAULT_MAX_ITEMS = 1e4;
76
77
  function isConfirmGateEnabled() {
77
78
  return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
78
79
  }
80
+ function auditHttpStatus(value) {
81
+ if (typeof value === "number" && Number.isInteger(value)) return value;
82
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
83
+ return void 0;
84
+ }
85
+ function auditErrorCode(value) {
86
+ return typeof value === "string" || typeof value === "number" ? value : void 0;
87
+ }
88
+ function auditNonNegativeInteger(value) {
89
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
90
+ }
91
+ function auditStringNumberMap(value) {
92
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
93
+ const entries = Object.entries(value).filter(
94
+ (entry) => typeof entry[1] === "number" && Number.isInteger(entry[1]) && entry[1] >= 0
95
+ );
96
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
97
+ }
98
+ function graphResponseAuditFields(response) {
99
+ const httpStatus = auditHttpStatus(response._meta?.http_status);
100
+ const errorCode = response.isError ? auditErrorCode(response._meta?.error_code) : void 0;
101
+ const graphBatchSubrequestCount = auditNonNegativeInteger(
102
+ response._meta?.graph_batch_subrequest_count
103
+ );
104
+ const graphBatchHttpStatusCounts = auditStringNumberMap(
105
+ response._meta?.graph_batch_http_status_counts
106
+ );
107
+ const graphBatchErrorCodeCounts = auditStringNumberMap(
108
+ response._meta?.graph_batch_error_code_counts
109
+ );
110
+ return {
111
+ ...httpStatus !== void 0 ? { http_status: httpStatus } : {},
112
+ ...errorCode !== void 0 ? { error_code: errorCode } : {},
113
+ ...graphBatchSubrequestCount !== void 0 ? { graph_batch_subrequest_count: graphBatchSubrequestCount } : {},
114
+ ...graphBatchHttpStatusCounts !== void 0 ? { graph_batch_http_status_counts: graphBatchHttpStatusCounts } : {},
115
+ ...graphBatchErrorCodeCounts !== void 0 ? { graph_batch_error_code_counts: graphBatchErrorCodeCounts } : {}
116
+ };
117
+ }
118
+ function thrownErrorAuditFields(error) {
119
+ const err = error;
120
+ const httpStatus = auditHttpStatus(err?.httpStatus ?? err?.status);
121
+ const errorCode = auditErrorCode(err?.graphErrorCode ?? err?.code ?? err?.status);
122
+ return {
123
+ ...httpStatus !== void 0 ? { http_status: httpStatus } : {},
124
+ ...errorCode !== void 0 ? { error_code: errorCode } : {}
125
+ };
126
+ }
127
+ async function executeUtilityTool(utility, ctx, params) {
128
+ const requestId = randomUUID();
129
+ const startTime = Date.now();
130
+ const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
131
+ try {
132
+ const response = await utility.execute(params, ctx);
133
+ auditLog({
134
+ event: "tool.call",
135
+ request_id: requestId,
136
+ user_principal_name: upn,
137
+ tool: utility.name,
138
+ http_method: utility.method.toUpperCase(),
139
+ status: response.isError ? "error" : "success",
140
+ duration_ms: Date.now() - startTime,
141
+ ...graphResponseAuditFields(response)
142
+ });
143
+ return response;
144
+ } catch (error) {
145
+ const err = error;
146
+ auditLog({
147
+ event: "tool.call",
148
+ request_id: requestId,
149
+ user_principal_name: upn,
150
+ tool: utility.name,
151
+ http_method: utility.method.toUpperCase(),
152
+ status: "error",
153
+ duration_ms: Date.now() - startTime,
154
+ error_type: err?.name || "Error",
155
+ ...thrownErrorAuditFields(error)
156
+ });
157
+ throw error;
158
+ }
159
+ }
79
160
  function formatDisabledToolsForLog(disabledTools) {
80
161
  const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
81
162
  const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
82
163
  return `${shown.join("; ")}${suffix}`;
83
164
  }
165
+ function deniedToolPolicyForGraphTool(tool, config, reason, missingScopes) {
166
+ return {
167
+ toolName: tool.alias,
168
+ reason,
169
+ ...missingScopes && missingScopes.length > 0 ? { missingScopes } : {},
170
+ pathPattern: config?.pathPattern ?? tool.path
171
+ };
172
+ }
173
+ function collectDeniedToolPolicies(options) {
174
+ const deniedTools = /* @__PURE__ */ new Map();
175
+ const allowedScopes = parseAllowedScopes(options.allowedScopesValue);
176
+ for (const tool of allEndpoints) {
177
+ const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
178
+ if (!options.orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
179
+ continue;
180
+ }
181
+ const method = tool.method.toUpperCase();
182
+ if (options.readOnly && method !== "GET" && !(method === "POST" && endpointConfig?.readOnly)) {
183
+ continue;
184
+ }
185
+ if (options.enabledToolsRegex && !options.enabledToolsRegex.test(tool.alias)) {
186
+ deniedTools.set(
187
+ tool.alias,
188
+ deniedToolPolicyForGraphTool(tool, endpointConfig, "tool_allowlist")
189
+ );
190
+ continue;
191
+ }
192
+ const missingScopes = allowedScopes !== void 0 && !endpointConfig ? ["endpoint scope metadata"] : getMissingAllowedScopesForGroups(
193
+ getEndpointScopeGroups(endpointConfig, options.orgMode),
194
+ allowedScopes
195
+ );
196
+ if (missingScopes.length > 0) {
197
+ deniedTools.set(
198
+ tool.alias,
199
+ deniedToolPolicyForGraphTool(tool, endpointConfig, "allowed_scopes", missingScopes)
200
+ );
201
+ }
202
+ }
203
+ for (const utility of UTILITY_TOOLS) {
204
+ if (options.readOnly && !utility.readOnlyHint) continue;
205
+ if (options.httpMode && utility.stdioOnly) continue;
206
+ if (options.enabledToolsRegex && !options.enabledToolsRegex.test(utility.name)) {
207
+ deniedTools.set(utility.name, {
208
+ toolName: utility.name,
209
+ reason: "tool_allowlist"
210
+ });
211
+ }
212
+ }
213
+ return deniedTools;
214
+ }
215
+ function auditToolDenied(policy, params = {}) {
216
+ const targetResource = policy.pathPattern ? deriveTargetResource({ pathPattern: policy.pathPattern, params }) : void 0;
217
+ auditLog({
218
+ event: "tool.denied",
219
+ request_id: randomUUID(),
220
+ user_principal_name: getUserIdentityForAudit(getRequestTokens()?.accessToken),
221
+ tool: policy.toolName,
222
+ status: "denied",
223
+ reason: policy.reason,
224
+ ...policy.missingScopes ? { missing_scopes: policy.missingScopes } : {},
225
+ ...targetResource ? { target_resource: targetResource } : {}
226
+ });
227
+ }
228
+ function installDeniedToolAuditHandler(server, deniedTools) {
229
+ if (deniedTools.size === 0) return;
230
+ const lowLevel = server.server;
231
+ const handlers = lowLevel._requestHandlers;
232
+ const original = handlers?.get("tools/call");
233
+ if (!original) {
234
+ logger.warn("Skipping denied-tool audit hook: tools/call handler not found");
235
+ return;
236
+ }
237
+ lowLevel.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
238
+ const policy = deniedTools.get(request.params.name);
239
+ if (policy) {
240
+ const params = request.params.arguments && typeof request.params.arguments === "object" ? request.params.arguments : {};
241
+ auditToolDenied(policy, params);
242
+ }
243
+ return original(request, extra);
244
+ });
245
+ }
84
246
  async function checkAccountParamInBearerMode(accountParam, authManager) {
85
247
  if (!accountParam || !authManager) return null;
86
248
  const contextToken = getRequestTokens()?.accessToken;
@@ -316,12 +478,15 @@ const UTILITY_TOOLS = [
316
478
  bytesWritten: result.contentLength
317
479
  })
318
480
  }
319
- ]
481
+ ],
482
+ ...result.httpStatus !== void 0 ? { _meta: { http_status: result.httpStatus } } : {}
320
483
  };
321
484
  } catch (error) {
485
+ const metadata = thrownErrorAuditFields(error);
322
486
  return {
323
487
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
324
- isError: true
488
+ isError: true,
489
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
325
490
  };
326
491
  }
327
492
  }
@@ -496,7 +661,8 @@ const UTILITY_TOOLS = [
496
661
  })
497
662
  }
498
663
  ],
499
- isError: true
664
+ isError: true,
665
+ _meta: response._meta
500
666
  };
501
667
  }
502
668
  const file = item?.file;
@@ -511,12 +677,15 @@ const UTILITY_TOOLS = [
511
677
  contentType: file?.mimeType
512
678
  })
513
679
  }
514
- ]
680
+ ],
681
+ _meta: response._meta
515
682
  };
516
683
  } catch (error) {
684
+ const metadata = thrownErrorAuditFields(error);
517
685
  return {
518
686
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
519
- isError: true
687
+ isError: true,
688
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
520
689
  };
521
690
  }
522
691
  }
@@ -532,7 +701,7 @@ function registerUtilityToolWithMcp(server, utility, ctx) {
532
701
  readOnlyHint: utility.readOnlyHint ?? true,
533
702
  openWorldHint: utility.openWorldHint ?? true
534
703
  },
535
- async (params) => utility.execute(params, ctx)
704
+ async (params) => executeUtilityTool(utility, ctx, params)
536
705
  );
537
706
  }
538
707
  function namesNestedBodyType(value) {
@@ -886,6 +1055,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
886
1055
  const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
887
1056
  const nextOptions = { ...options };
888
1057
  const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
1058
+ if (nextResponse?.isError) {
1059
+ response = nextResponse;
1060
+ combinedResponse = void 0;
1061
+ break;
1062
+ }
889
1063
  if (nextResponse?.content?.[0]?.text) {
890
1064
  const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
891
1065
  if (Array.isArray(nextJsonResponse.value)) {
@@ -900,25 +1074,27 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
900
1074
  break;
901
1075
  }
902
1076
  }
903
- if (pageCount >= maxPages) {
904
- logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
905
- }
906
- if (allItems.length >= maxItems) {
907
- logger.warn(
908
- `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
1077
+ if (combinedResponse !== void 0) {
1078
+ if (pageCount >= maxPages) {
1079
+ logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
1080
+ }
1081
+ if (allItems.length >= maxItems) {
1082
+ logger.warn(
1083
+ `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
1084
+ );
1085
+ }
1086
+ combinedResponse.value = allItems;
1087
+ if (combinedResponse["@odata.count"]) {
1088
+ combinedResponse["@odata.count"] = allItems.length;
1089
+ }
1090
+ delete combinedResponse["@odata.nextLink"];
1091
+ if (deltaLink) {
1092
+ combinedResponse["@odata.deltaLink"] = deltaLink;
1093
+ }
1094
+ logger.info(
1095
+ `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
909
1096
  );
910
1097
  }
911
- combinedResponse.value = allItems;
912
- if (combinedResponse["@odata.count"]) {
913
- combinedResponse["@odata.count"] = allItems.length;
914
- }
915
- delete combinedResponse["@odata.nextLink"];
916
- if (deltaLink) {
917
- combinedResponse["@odata.deltaLink"] = deltaLink;
918
- }
919
- logger.info(
920
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
921
- );
922
1098
  }
923
1099
  } catch (e) {
924
1100
  logger.error(`Error during pagination: ${e}`);
@@ -953,7 +1129,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
953
1129
  http_method: httpMethod,
954
1130
  status: response.isError ? "error" : "success",
955
1131
  duration_ms: Date.now() - startTime,
956
- ...targetResource ? { target_resource: targetResource } : {}
1132
+ ...targetResource ? { target_resource: targetResource } : {},
1133
+ ...graphResponseAuditFields(response)
957
1134
  });
958
1135
  return {
959
1136
  content,
@@ -973,7 +1150,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
973
1150
  duration_ms: Date.now() - startTime,
974
1151
  ...targetResource ? { target_resource: targetResource } : {},
975
1152
  error_type: err?.name || "Error",
976
- error_code: err?.status ?? err?.code
1153
+ ...thrownErrorAuditFields(error)
977
1154
  });
978
1155
  return {
979
1156
  content: [
@@ -1003,6 +1180,13 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1003
1180
  let failedCount = 0;
1004
1181
  const allowedScopes = parseAllowedScopes(allowedScopesValue);
1005
1182
  const disabledByAllowedScopes = [];
1183
+ const deniedTools = collectDeniedToolPolicies({
1184
+ readOnly,
1185
+ orgMode,
1186
+ enabledToolsRegex,
1187
+ allowedScopesValue,
1188
+ httpMode
1189
+ });
1006
1190
  for (const tool of allEndpoints) {
1007
1191
  const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
1008
1192
  if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
@@ -1161,6 +1345,7 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
1161
1345
  logger.info(
1162
1346
  `Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped, ${failedCount} failed`
1163
1347
  );
1348
+ installDeniedToolAuditHandler(server, deniedTools);
1164
1349
  return registeredCount;
1165
1350
  }
1166
1351
  function buildToolsRegistry(readOnly, orgMode, enabledToolsRegex, allowedScopesValue, disabledByAllowedScopes = []) {
@@ -1277,6 +1462,13 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1277
1462
  }
1278
1463
  }
1279
1464
  const disabledByAllowedScopes = [];
1465
+ const deniedTools = collectDeniedToolPolicies({
1466
+ readOnly,
1467
+ orgMode,
1468
+ enabledToolsRegex,
1469
+ allowedScopesValue,
1470
+ httpMode
1471
+ });
1280
1472
  const toolsRegistry = buildToolsRegistry(
1281
1473
  readOnly,
1282
1474
  orgMode,
@@ -1450,7 +1642,11 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1450
1642
  }
1451
1643
  const utility = utilityByName.get(tool_name);
1452
1644
  if (utility) {
1453
- return utility.execute(parameters, utilityCtx);
1645
+ return executeUtilityTool(utility, utilityCtx, parameters);
1646
+ }
1647
+ const deniedPolicy = deniedTools.get(tool_name);
1648
+ if (deniedPolicy) {
1649
+ auditToolDenied(deniedPolicy, parameters);
1454
1650
  }
1455
1651
  return {
1456
1652
  content: [
@@ -1466,6 +1662,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1466
1662
  };
1467
1663
  }
1468
1664
  );
1665
+ installDeniedToolAuditHandler(server, deniedTools);
1469
1666
  }
1470
1667
  export {
1471
1668
  UTILITY_TOOLS,
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
213
213
  - **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
214
214
  - **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
215
215
  - **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
216
- - **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (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, target_resource?, error_type?, error_code? }`. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, tool parameters, returned content, 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`
216
+ - **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (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, target_resource?, error_type?, error_code? }`. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, tool parameters, returned content, 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`
217
217
  - **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`
218
218
  - **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
219
219
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softeria/ms-365-mcp-server",
3
3
  "mcpName": "io.github.Softeria/ms-365-mcp-server",
4
- "version": "0.143.0",
4
+ "version": "0.145.0",
5
5
  "description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -19,7 +19,8 @@
19
19
  "format:check": "prettier --check \"**/*.{ts,mts,js,mjs,json,md}\"",
20
20
  "lint": "eslint .",
21
21
  "lint:fix": "eslint . --fix",
22
- "verify": "npm run generate && npm run lint && npm run format:check && npm run build && npm run test",
22
+ "typecheck": "tsc --noEmit",
23
+ "verify": "npm run generate && npm run lint && npm run format:check && npm run typecheck && npm run build && npm run test",
23
24
  "inspector": "npx @modelcontextprotocol/inspector tsx src/index.ts"
24
25
  },
25
26
  "keywords": [