@softeria/ms-365-mcp-server 0.112.1 → 0.113.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/audit-log.js +72 -0
- package/dist/crash-logging.js +41 -0
- package/dist/graph-tools.js +27 -0
- package/dist/index.js +20 -0
- package/dist/server.js +4 -0
- package/docs/deployment.md +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import winston from "winston";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
const logsDir = process.env.MS365_MCP_LOG_DIR || path.join(os.homedir(), ".ms-365-mcp-server", "logs");
|
|
6
|
+
const FILE_MODE = 384;
|
|
7
|
+
const auditLogPath = path.join(logsDir, "audit.log");
|
|
8
|
+
try {
|
|
9
|
+
if (!fs.existsSync(logsDir)) {
|
|
10
|
+
fs.mkdirSync(logsDir, { recursive: true, mode: 448 });
|
|
11
|
+
}
|
|
12
|
+
if (fs.existsSync(auditLogPath)) {
|
|
13
|
+
fs.chmodSync(auditLogPath, FILE_MODE);
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
const auditLogger = winston.createLogger({
|
|
18
|
+
level: "info",
|
|
19
|
+
format: winston.format.combine(
|
|
20
|
+
winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
|
|
21
|
+
winston.format.json()
|
|
22
|
+
),
|
|
23
|
+
defaultMeta: {
|
|
24
|
+
service: "ms-365-mcp-server",
|
|
25
|
+
stream: "audit"
|
|
26
|
+
},
|
|
27
|
+
transports: [
|
|
28
|
+
new winston.transports.Console({
|
|
29
|
+
// Route audit events to stderr so they don't collide with JSON-RPC on
|
|
30
|
+
// stdout when this server runs in stdio mode. Container platforms
|
|
31
|
+
// (Container Apps, App Service, Docker) capture both stdout and stderr
|
|
32
|
+
// and forward to Log Analytics, so the production audit sink is
|
|
33
|
+
// unaffected. Vitest sets `VITEST=true`; staying silent there avoids
|
|
34
|
+
// polluting unrelated tests that exercise the real graph-tools module.
|
|
35
|
+
stderrLevels: ["info"],
|
|
36
|
+
silent: process.env.SILENT === "true" || process.env.SILENT === "1" || process.env.VITEST === "true"
|
|
37
|
+
}),
|
|
38
|
+
new winston.transports.File({
|
|
39
|
+
filename: auditLogPath,
|
|
40
|
+
options: { flags: "a", mode: FILE_MODE }
|
|
41
|
+
})
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
function isAuditLogEnabled() {
|
|
45
|
+
return process.env.MS365_MCP_AUDIT_LOG !== "false";
|
|
46
|
+
}
|
|
47
|
+
function auditLog(evt) {
|
|
48
|
+
if (!isAuditLogEnabled()) return;
|
|
49
|
+
auditLogger.info(evt);
|
|
50
|
+
}
|
|
51
|
+
function getUserIdentityForAudit(token) {
|
|
52
|
+
if (!token) return void 0;
|
|
53
|
+
try {
|
|
54
|
+
const parts = token.split(".");
|
|
55
|
+
if (parts.length < 2) return void 0;
|
|
56
|
+
let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
57
|
+
const padNeeded = (4 - b64.length % 4) % 4;
|
|
58
|
+
b64 = b64 + "=".repeat(padNeeded);
|
|
59
|
+
const payload = JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
|
|
60
|
+
const candidate = payload.upn || payload.preferred_username || payload.email || payload.sub;
|
|
61
|
+
return typeof candidate === "string" ? candidate : void 0;
|
|
62
|
+
} catch {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const __testing = { auditLogger, auditLogPath };
|
|
67
|
+
export {
|
|
68
|
+
__testing,
|
|
69
|
+
auditLog,
|
|
70
|
+
getUserIdentityForAudit,
|
|
71
|
+
isAuditLogEnabled
|
|
72
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
function dumpError(reason, depth = 0) {
|
|
2
|
+
if (depth > 5) {
|
|
3
|
+
return { truncated: true };
|
|
4
|
+
}
|
|
5
|
+
if (reason instanceof Error) {
|
|
6
|
+
const dump = {
|
|
7
|
+
name: reason.name,
|
|
8
|
+
constructor: reason.constructor?.name,
|
|
9
|
+
message: reason.message,
|
|
10
|
+
stack: reason.stack
|
|
11
|
+
};
|
|
12
|
+
const properties = {};
|
|
13
|
+
for (const key of Object.getOwnPropertyNames(reason)) {
|
|
14
|
+
if (key === "name" || key === "message" || key === "stack" || key === "cause") continue;
|
|
15
|
+
properties[key] = reason[key];
|
|
16
|
+
}
|
|
17
|
+
if (Object.keys(properties).length > 0) {
|
|
18
|
+
dump.properties = properties;
|
|
19
|
+
}
|
|
20
|
+
if ("cause" in reason && reason.cause !== void 0) {
|
|
21
|
+
dump.cause = dumpError(reason.cause, depth + 1);
|
|
22
|
+
}
|
|
23
|
+
return dump;
|
|
24
|
+
}
|
|
25
|
+
return { type: typeof reason, value: reason };
|
|
26
|
+
}
|
|
27
|
+
function getActiveResources() {
|
|
28
|
+
const fn = process.getActiveResourcesInfo;
|
|
29
|
+
if (typeof fn !== "function") {
|
|
30
|
+
return "unavailable (node < 17.3)";
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return fn.call(process);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return `error: ${err.message}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
dumpError,
|
|
40
|
+
getActiveResources
|
|
41
|
+
};
|
package/dist/graph-tools.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
1
2
|
import logger from "./logger.js";
|
|
3
|
+
import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
|
|
2
4
|
import {
|
|
3
5
|
getEndpointRequiredScopes,
|
|
4
6
|
getMissingAllowedScopes,
|
|
@@ -151,6 +153,10 @@ function registerUtilityToolWithMcp(server, utility, ctx) {
|
|
|
151
153
|
}
|
|
152
154
|
async function executeGraphTool(tool, config, graphClient, params, authManager) {
|
|
153
155
|
logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
|
|
156
|
+
const requestId = randomUUID();
|
|
157
|
+
const startTime = Date.now();
|
|
158
|
+
const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
|
|
159
|
+
const httpMethod = tool.method.toUpperCase();
|
|
154
160
|
try {
|
|
155
161
|
let accountAccessToken;
|
|
156
162
|
if (authManager && !authManager.isOAuthModeEnabled() && !getRequestTokens()) {
|
|
@@ -394,13 +400,34 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
394
400
|
type: "text",
|
|
395
401
|
text: item.text
|
|
396
402
|
}));
|
|
403
|
+
auditLog({
|
|
404
|
+
event: "tool.call",
|
|
405
|
+
request_id: requestId,
|
|
406
|
+
user_principal_name: upn,
|
|
407
|
+
tool: tool.alias,
|
|
408
|
+
http_method: httpMethod,
|
|
409
|
+
status: response.isError ? "error" : "success",
|
|
410
|
+
duration_ms: Date.now() - startTime
|
|
411
|
+
});
|
|
397
412
|
return {
|
|
398
413
|
content,
|
|
399
414
|
_meta: response._meta,
|
|
400
415
|
isError: response.isError
|
|
401
416
|
};
|
|
402
417
|
} catch (error) {
|
|
418
|
+
const err = error;
|
|
403
419
|
logger.error(`Error in tool ${tool.alias}: ${error.message}`);
|
|
420
|
+
auditLog({
|
|
421
|
+
event: "tool.call",
|
|
422
|
+
request_id: requestId,
|
|
423
|
+
user_principal_name: upn,
|
|
424
|
+
tool: tool.alias,
|
|
425
|
+
http_method: httpMethod,
|
|
426
|
+
status: "error",
|
|
427
|
+
duration_ms: Date.now() - startTime,
|
|
428
|
+
error_type: err?.name || "Error",
|
|
429
|
+
error_code: err?.status ?? err?.code
|
|
430
|
+
});
|
|
404
431
|
return {
|
|
405
432
|
content: [
|
|
406
433
|
{
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,27 @@ import {
|
|
|
10
10
|
shouldUseLocalAuthStorage
|
|
11
11
|
} from "./startup-pinning.js";
|
|
12
12
|
import { createTokenCacheStorage } from "./token-cache-storage.js";
|
|
13
|
+
import { dumpError, getActiveResources } from "./crash-logging.js";
|
|
13
14
|
import { version } from "./version.js";
|
|
15
|
+
process.on("unhandledRejection", (reason) => {
|
|
16
|
+
const dump = {
|
|
17
|
+
kind: "unhandledRejection",
|
|
18
|
+
reason: dumpError(reason),
|
|
19
|
+
activeResources: getActiveResources()
|
|
20
|
+
};
|
|
21
|
+
console.error("[ms365-mcp] unhandledRejection", JSON.stringify(dump));
|
|
22
|
+
logger.error("unhandledRejection", dump);
|
|
23
|
+
});
|
|
24
|
+
process.on("uncaughtException", (err, origin) => {
|
|
25
|
+
const dump = {
|
|
26
|
+
kind: "uncaughtException",
|
|
27
|
+
origin,
|
|
28
|
+
error: dumpError(err),
|
|
29
|
+
activeResources: getActiveResources()
|
|
30
|
+
};
|
|
31
|
+
console.error("[ms365-mcp] uncaughtException", JSON.stringify(dump));
|
|
32
|
+
logger.error("uncaughtException", dump);
|
|
33
|
+
});
|
|
14
34
|
async function main() {
|
|
15
35
|
try {
|
|
16
36
|
const args = parseArgs();
|
package/dist/server.js
CHANGED
|
@@ -25,6 +25,7 @@ import { isAllowedRedirectUri, parseAllowlist } from "./lib/redirect-uri-validat
|
|
|
25
25
|
import { getSecrets } from "./secrets.js";
|
|
26
26
|
import { getCloudEndpoints } from "./cloud-config.js";
|
|
27
27
|
import { requestContext } from "./request-context.js";
|
|
28
|
+
import { dumpError } from "./crash-logging.js";
|
|
28
29
|
import crypto from "node:crypto";
|
|
29
30
|
import OboClient from "./obo-client.js";
|
|
30
31
|
function parseHttpOption(httpOption) {
|
|
@@ -537,6 +538,9 @@ class MicrosoftGraphServer {
|
|
|
537
538
|
}
|
|
538
539
|
} else {
|
|
539
540
|
const transport = new StdioServerTransport();
|
|
541
|
+
transport.onerror = (error) => {
|
|
542
|
+
logger.error("Stdio transport error", { error: dumpError(error) });
|
|
543
|
+
};
|
|
540
544
|
await this.server.connect(transport);
|
|
541
545
|
logger.info("Server connected to stdio transport");
|
|
542
546
|
}
|
package/docs/deployment.md
CHANGED
|
@@ -208,6 +208,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
208
208
|
- **Read-only mode**: use `--read-only` to disable all write operations (send, delete, update, create)
|
|
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
|
+
- **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`
|
|
211
212
|
|
|
212
213
|
## Exposed Endpoints
|
|
213
214
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softeria/ms-365-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.113.0",
|
|
4
4
|
"description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|