@softeria/ms-365-mcp-server 0.142.0 → 0.144.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.
@@ -7,6 +7,7 @@ import {
7
7
  getSharedBreaker,
8
8
  loadResilienceConfig
9
9
  } from "./lib/graph-resilience.js";
10
+ import { applyMessageSignoffToRequest } from "./lib/message-signoff.js";
10
11
  import { open, stat, unlink } from "fs/promises";
11
12
  import { pipeline } from "stream/promises";
12
13
  function isBinaryContentType(contentType) {
@@ -30,6 +31,62 @@ function isBinaryContentType(contentType) {
30
31
  }
31
32
  return false;
32
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
+ }
33
90
  class GraphClient {
34
91
  constructor(authManager, secrets, outputFormat = "json") {
35
92
  this.outputFormat = "json";
@@ -38,6 +95,9 @@ class GraphClient {
38
95
  this.outputFormat = outputFormat;
39
96
  }
40
97
  async makeRequest(endpoint, options = {}) {
98
+ return (await this.makeRequestWithMetadata(endpoint, options)).data;
99
+ }
100
+ async makeRequestWithMetadata(endpoint, options = {}) {
41
101
  const contextTokens = getRequestTokens();
42
102
  const accessToken = options.accessToken ?? contextTokens?.accessToken ?? await this.authManager.getToken();
43
103
  if (!accessToken) {
@@ -46,23 +106,14 @@ class GraphClient {
46
106
  try {
47
107
  const response = await this.performRequest(endpoint, accessToken, options);
48
108
  if (response.status === 403) {
49
- const errorText = await response.text();
50
- if (errorText.includes("scope") || errorText.includes("permission")) {
51
- throw new Error(
52
- `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
53
- );
54
- }
55
- throw new Error(
56
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
57
- );
109
+ throw await this.createGraphApiError(response);
58
110
  }
59
111
  if (!response.ok) {
60
- throw new Error(
61
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
62
- );
112
+ throw await this.createGraphApiError(response);
63
113
  }
64
114
  const contentTypeHeader = response.headers?.get?.("content-type") || "";
65
115
  const isBinaryResponse = isBinaryContentType(contentTypeHeader);
116
+ let metadata = { http_status: response.status };
66
117
  let result;
67
118
  if (isBinaryResponse) {
68
119
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -87,16 +138,22 @@ class GraphClient {
87
138
  }
88
139
  }
89
140
  }
141
+ if (endpoint === "/$batch") {
142
+ metadata = { ...metadata, ...extractBatchMetadata(result) };
143
+ }
90
144
  if (options.includeHeaders) {
91
145
  const etag = response.headers.get("ETag") || response.headers.get("etag");
92
146
  if (result && typeof result === "object" && !Array.isArray(result)) {
93
147
  return {
94
- ...result,
95
- _etag: etag || "no-etag-found"
148
+ data: {
149
+ ...result,
150
+ _etag: etag || "no-etag-found"
151
+ },
152
+ metadata
96
153
  };
97
154
  }
98
155
  }
99
- return result;
156
+ return { data: result, metadata };
100
157
  } catch (error) {
101
158
  logger.error("Microsoft Graph API request failed:", error);
102
159
  throw error;
@@ -120,20 +177,10 @@ class GraphClient {
120
177
  }
121
178
  const response = await this.performRequest(endpoint, accessToken, options);
122
179
  if (response.status === 403) {
123
- const errorText = await response.text();
124
- if (errorText.includes("scope") || errorText.includes("permission")) {
125
- throw new Error(
126
- `Microsoft Graph API scope error: ${response.status} ${response.statusText} - ${errorText}. This tool requires organization mode. Please restart with --org-mode flag.`
127
- );
128
- }
129
- throw new Error(
130
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${errorText}`
131
- );
180
+ throw await this.createGraphApiError(response);
132
181
  }
133
182
  if (!response.ok) {
134
- throw new Error(
135
- `Microsoft Graph API error: ${response.status} ${response.statusText} - ${await response.text()}`
136
- );
183
+ throw await this.createGraphApiError(response);
137
184
  }
138
185
  if (!response.body) {
139
186
  throw new Error("Microsoft Graph returned an empty response body");
@@ -149,7 +196,8 @@ class GraphClient {
149
196
  }
150
197
  return {
151
198
  contentType: response.headers.get("content-type") || "application/octet-stream",
152
- contentLength
199
+ contentLength,
200
+ httpStatus: response.status
153
201
  };
154
202
  } catch (error) {
155
203
  logger.error("Microsoft Graph file download failed:", error);
@@ -161,11 +209,30 @@ class GraphClient {
161
209
  }
162
210
  }
163
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
+ }
164
229
  async performRequest(endpoint, accessToken, options) {
165
230
  const cloudEndpoints = getCloudEndpoints(this.secrets.cloudType);
166
231
  const apiVersion = options.apiVersion || "v1.0";
167
232
  const url = `${cloudEndpoints.graphApi}/${apiVersion}${endpoint}`;
168
233
  logger.info(`[GRAPH CLIENT] Final URL being sent to Microsoft: ${url}`);
234
+ const method = options.method || "GET";
235
+ const body = applyMessageSignoffToRequest(method, endpoint, options.body);
169
236
  const headers = {
170
237
  Authorization: `Bearer ${accessToken}`,
171
238
  "Content-Type": "application/json",
@@ -174,10 +241,10 @@ class GraphClient {
174
241
  return fetchWithResilience(
175
242
  url,
176
243
  {
177
- method: options.method || "GET",
244
+ method,
178
245
  headers,
179
246
  // Node's fetch accepts Buffer/Uint8Array; TS BodyInit doesn't.
180
- body: options.body
247
+ body
181
248
  },
182
249
  loadResilienceConfig(),
183
250
  getSharedBreaker()
@@ -208,22 +275,36 @@ class GraphClient {
208
275
  logger.info(
209
276
  `Calling ${endpoint} with options: ${JSON.stringify(safeOptions)}${_redacted ? " [accessToken=REDACTED]" : ""}`
210
277
  );
211
- const result = await this.makeRequest(endpoint, options);
278
+ const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
212
279
  const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
213
- return this.formatJsonResponse(
214
- result,
215
- options.rawResponse,
216
- options.excludeResponse,
217
- outputFormat
280
+ return this.withResponseMetadata(
281
+ this.formatJsonResponse(data, options.rawResponse, options.excludeResponse, outputFormat),
282
+ metadata
218
283
  );
219
284
  } catch (error) {
220
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;
221
290
  return {
222
291
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
223
- isError: true
292
+ isError: true,
293
+ ...metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
224
294
  };
225
295
  }
226
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
+ }
227
308
  formatJsonResponse(data, rawResponse = false, excludeResponse = false, outputFormat = this.outputFormat) {
228
309
  if (excludeResponse) {
229
310
  return {
@@ -299,6 +380,7 @@ class GraphClient {
299
380
  }
300
381
  var graph_client_default = GraphClient;
301
382
  export {
383
+ GraphApiError,
302
384
  graph_client_default as default,
303
385
  isBinaryContentType
304
386
  };
@@ -76,6 +76,86 @@ const DEFAULT_MAX_ITEMS = 1e4;
76
76
  function isConfirmGateEnabled() {
77
77
  return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
78
78
  }
79
+ function auditHttpStatus(value) {
80
+ if (typeof value === "number" && Number.isInteger(value)) return value;
81
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
82
+ return void 0;
83
+ }
84
+ function auditErrorCode(value) {
85
+ return typeof value === "string" || typeof value === "number" ? value : void 0;
86
+ }
87
+ function auditNonNegativeInteger(value) {
88
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
89
+ }
90
+ function auditStringNumberMap(value) {
91
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
92
+ const entries = Object.entries(value).filter(
93
+ (entry) => typeof entry[1] === "number" && Number.isInteger(entry[1]) && entry[1] >= 0
94
+ );
95
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
96
+ }
97
+ function graphResponseAuditFields(response) {
98
+ const httpStatus = auditHttpStatus(response._meta?.http_status);
99
+ const errorCode = response.isError ? auditErrorCode(response._meta?.error_code) : void 0;
100
+ const graphBatchSubrequestCount = auditNonNegativeInteger(
101
+ response._meta?.graph_batch_subrequest_count
102
+ );
103
+ const graphBatchHttpStatusCounts = auditStringNumberMap(
104
+ response._meta?.graph_batch_http_status_counts
105
+ );
106
+ const graphBatchErrorCodeCounts = auditStringNumberMap(
107
+ response._meta?.graph_batch_error_code_counts
108
+ );
109
+ return {
110
+ ...httpStatus !== void 0 ? { http_status: httpStatus } : {},
111
+ ...errorCode !== void 0 ? { error_code: errorCode } : {},
112
+ ...graphBatchSubrequestCount !== void 0 ? { graph_batch_subrequest_count: graphBatchSubrequestCount } : {},
113
+ ...graphBatchHttpStatusCounts !== void 0 ? { graph_batch_http_status_counts: graphBatchHttpStatusCounts } : {},
114
+ ...graphBatchErrorCodeCounts !== void 0 ? { graph_batch_error_code_counts: graphBatchErrorCodeCounts } : {}
115
+ };
116
+ }
117
+ function thrownErrorAuditFields(error) {
118
+ const err = error;
119
+ const httpStatus = auditHttpStatus(err?.httpStatus ?? err?.status);
120
+ const errorCode = auditErrorCode(err?.graphErrorCode ?? err?.code ?? err?.status);
121
+ return {
122
+ ...httpStatus !== void 0 ? { http_status: httpStatus } : {},
123
+ ...errorCode !== void 0 ? { error_code: errorCode } : {}
124
+ };
125
+ }
126
+ async function executeUtilityTool(utility, ctx, params) {
127
+ const requestId = randomUUID();
128
+ const startTime = Date.now();
129
+ const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
130
+ try {
131
+ const response = await utility.execute(params, ctx);
132
+ auditLog({
133
+ event: "tool.call",
134
+ request_id: requestId,
135
+ user_principal_name: upn,
136
+ tool: utility.name,
137
+ http_method: utility.method.toUpperCase(),
138
+ status: response.isError ? "error" : "success",
139
+ duration_ms: Date.now() - startTime,
140
+ ...graphResponseAuditFields(response)
141
+ });
142
+ return response;
143
+ } catch (error) {
144
+ const err = error;
145
+ auditLog({
146
+ event: "tool.call",
147
+ request_id: requestId,
148
+ user_principal_name: upn,
149
+ tool: utility.name,
150
+ http_method: utility.method.toUpperCase(),
151
+ status: "error",
152
+ duration_ms: Date.now() - startTime,
153
+ error_type: err?.name || "Error",
154
+ ...thrownErrorAuditFields(error)
155
+ });
156
+ throw error;
157
+ }
158
+ }
79
159
  function formatDisabledToolsForLog(disabledTools) {
80
160
  const shown = disabledTools.slice(0, 20).map((tool) => `${tool.toolName} (missing: ${tool.missingScopes.join(", ")})`);
81
161
  const suffix = disabledTools.length > shown.length ? `, ... +${disabledTools.length - shown.length} more` : "";
@@ -316,12 +396,15 @@ const UTILITY_TOOLS = [
316
396
  bytesWritten: result.contentLength
317
397
  })
318
398
  }
319
- ]
399
+ ],
400
+ ...result.httpStatus !== void 0 ? { _meta: { http_status: result.httpStatus } } : {}
320
401
  };
321
402
  } catch (error) {
403
+ const metadata = thrownErrorAuditFields(error);
322
404
  return {
323
405
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
324
- isError: true
406
+ isError: true,
407
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
325
408
  };
326
409
  }
327
410
  }
@@ -496,7 +579,8 @@ const UTILITY_TOOLS = [
496
579
  })
497
580
  }
498
581
  ],
499
- isError: true
582
+ isError: true,
583
+ _meta: response._meta
500
584
  };
501
585
  }
502
586
  const file = item?.file;
@@ -511,12 +595,15 @@ const UTILITY_TOOLS = [
511
595
  contentType: file?.mimeType
512
596
  })
513
597
  }
514
- ]
598
+ ],
599
+ _meta: response._meta
515
600
  };
516
601
  } catch (error) {
602
+ const metadata = thrownErrorAuditFields(error);
517
603
  return {
518
604
  content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
519
- isError: true
605
+ isError: true,
606
+ ...Object.keys(metadata).length > 0 ? { _meta: metadata } : {}
520
607
  };
521
608
  }
522
609
  }
@@ -532,7 +619,7 @@ function registerUtilityToolWithMcp(server, utility, ctx) {
532
619
  readOnlyHint: utility.readOnlyHint ?? true,
533
620
  openWorldHint: utility.openWorldHint ?? true
534
621
  },
535
- async (params) => utility.execute(params, ctx)
622
+ async (params) => executeUtilityTool(utility, ctx, params)
536
623
  );
537
624
  }
538
625
  function namesNestedBodyType(value) {
@@ -886,6 +973,11 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
886
973
  const nextPath = url.pathname.replace(/^\/(v1\.0|beta)/, "") + url.search;
887
974
  const nextOptions = { ...options };
888
975
  const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
976
+ if (nextResponse?.isError) {
977
+ response = nextResponse;
978
+ combinedResponse = void 0;
979
+ break;
980
+ }
889
981
  if (nextResponse?.content?.[0]?.text) {
890
982
  const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
891
983
  if (Array.isArray(nextJsonResponse.value)) {
@@ -900,25 +992,27 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
900
992
  break;
901
993
  }
902
994
  }
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`
995
+ if (combinedResponse !== void 0) {
996
+ if (pageCount >= maxPages) {
997
+ logger.warn(`Reached maximum page limit (${maxPages}) for pagination`);
998
+ }
999
+ if (allItems.length >= maxItems) {
1000
+ logger.warn(
1001
+ `Reached maximum item limit (${maxItems}) for pagination \u2014 truncated at ${allItems.length} items`
1002
+ );
1003
+ }
1004
+ combinedResponse.value = allItems;
1005
+ if (combinedResponse["@odata.count"]) {
1006
+ combinedResponse["@odata.count"] = allItems.length;
1007
+ }
1008
+ delete combinedResponse["@odata.nextLink"];
1009
+ if (deltaLink) {
1010
+ combinedResponse["@odata.deltaLink"] = deltaLink;
1011
+ }
1012
+ logger.info(
1013
+ `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
909
1014
  );
910
1015
  }
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
1016
  }
923
1017
  } catch (e) {
924
1018
  logger.error(`Error during pagination: ${e}`);
@@ -953,7 +1047,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
953
1047
  http_method: httpMethod,
954
1048
  status: response.isError ? "error" : "success",
955
1049
  duration_ms: Date.now() - startTime,
956
- ...targetResource ? { target_resource: targetResource } : {}
1050
+ ...targetResource ? { target_resource: targetResource } : {},
1051
+ ...graphResponseAuditFields(response)
957
1052
  });
958
1053
  return {
959
1054
  content,
@@ -973,7 +1068,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
973
1068
  duration_ms: Date.now() - startTime,
974
1069
  ...targetResource ? { target_resource: targetResource } : {},
975
1070
  error_type: err?.name || "Error",
976
- error_code: err?.status ?? err?.code
1071
+ ...thrownErrorAuditFields(error)
977
1072
  });
978
1073
  return {
979
1074
  content: [
@@ -1450,7 +1545,7 @@ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode =
1450
1545
  }
1451
1546
  const utility = utilityByName.get(tool_name);
1452
1547
  if (utility) {
1453
- return utility.execute(parameters, utilityCtx);
1548
+ return executeUtilityTool(utility, utilityCtx, parameters);
1454
1549
  }
1455
1550
  return {
1456
1551
  content: [