@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.
- package/dist/__tests__/graph-client.test.js +128 -0
- package/dist/__tests__/graph-tools.test.js +527 -31
- package/dist/auth-tools.js +0 -1
- package/dist/graph-client.js +114 -35
- package/dist/graph-tools.js +223 -26
- package/docs/deployment.md +1 -1
- package/package.json +3 -2
package/dist/auth-tools.js
CHANGED
package/dist/graph-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
278
|
+
const { data, metadata } = await this.makeRequestWithMetadata(endpoint, options);
|
|
215
279
|
const outputFormat = options.forceJsonOutput ? "json" : this.outputFormat;
|
|
216
|
-
return this.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
};
|
package/dist/graph-tools.js
CHANGED
|
@@ -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
|
|
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 (
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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": [
|