diff --git a/package-lock.json b/package-lock.json index 78e5bfdb..83af188c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@types/express": "^5.0.1", "bson": "^6.10.3", "mongodb": "^6.15.0", + "mongodb-log-writer": "^2.4.1", + "mongodb-redact": "^1.1.6", "mongodb-schema": "^12.6.2", "zod": "^3.24.2" }, @@ -7176,7 +7178,6 @@ "resolved": "https://registry.npmjs.org/heap-js/-/heap-js-2.6.0.tgz", "integrity": "sha512-trFMIq3PATiFRiQmNNeHtsrkwYRByIXUbYNbotiY9RLVfMkdwZdd2eQ38mGt7BRiCKBaj1DyBAIHmm7mmXPuuw==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=10.0.0" } @@ -8177,7 +8178,6 @@ "resolved": "https://registry.npmjs.org/mongodb-log-writer/-/mongodb-log-writer-2.4.1.tgz", "integrity": "sha512-kTVWtiUbayr2S54WeOeHpXvR80ASwlmoMsA3LIxH+PVZle8ddq7cXJXM3O5kkuT+Uni9+YNOTBwoRYVQlIAEUQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "heap-js": "^2.3.0" }, @@ -8192,6 +8192,12 @@ "license": "MIT", "optional": true }, + "node_modules/mongodb-redact": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/mongodb-redact/-/mongodb-redact-1.1.6.tgz", + "integrity": "sha512-L4L3byUH/V/L6YH954NBM/zJpyDHQYmm9eUCxMxqMUfiYCPtmCK1sv/LhxE7UonOkFNEAT6eq2J8gIWGUpHcJA==", + "license": "Apache-2.0" + }, "node_modules/mongodb-schema": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/mongodb-schema/-/mongodb-schema-12.6.2.tgz", diff --git a/package.json b/package.json index 25a0da88..94a85898 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "@types/express": "^5.0.1", "bson": "^6.10.3", "mongodb": "^6.15.0", + "mongodb-log-writer": "^2.4.1", + "mongodb-redact": "^1.1.6", "mongodb-schema": "^12.6.2", "zod": "^3.24.2" }, diff --git a/src/config.ts b/src/config.ts index 75e22803..5d38edba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; +import os from "os"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -15,6 +16,19 @@ export const config = { stateFile: process.env.STATE_FILE || path.resolve("./state.json"), projectID: process.env.PROJECT_ID, userAgent: `AtlasMCP/${packageJson.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`, + localDataPath: getLocalDataPath(), }; export default config; + +function getLocalDataPath() { + if (process.platform === "win32") { + const appData = process.env.APPDATA; + const localAppData = process.env.LOCALAPPDATA ?? process.env.APPDATA; + if (localAppData && appData) { + return path.join(localAppData, "mongodb", "mongodb-mcp"); + } + } + + return path.join(os.homedir(), ".mongodb", "mongodb-mcp"); +} diff --git a/src/index.ts b/src/index.ts index 5c80237b..b2afac80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Server } from "./server.js"; +import logger from "./logger.js"; +import { mongoLogId } from "mongodb-log-writer"; async function runServer() { const server = new Server(); @@ -9,6 +11,7 @@ async function runServer() { } runServer().catch((error) => { - console.error(`Fatal error running server:`, error); + logger.emergency(mongoLogId(1_000_004), "server", `Fatal error running server: ${error}`); + process.exit(1); }); diff --git a/src/logger.ts b/src/logger.ts index 4e7f6f08..d5415a74 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,119 @@ -// TODO: use a proper logger here -export function log(level: string, message: string) { - console.error(`[${level.toUpperCase()}] ${message}`); +import { MongoLogId, MongoLogManager, MongoLogWriter } from "mongodb-log-writer"; +import path from "path"; +import config from "./config.js"; +import redact from "mongodb-redact"; +import fs from "fs/promises"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { LoggingMessageNotification } from "@modelcontextprotocol/sdk/types.js"; + +export type LogLevel = LoggingMessageNotification["params"]["level"]; + +abstract class LoggerBase { + abstract log(level: LogLevel, id: MongoLogId, context: string, message: string): void; + info(id: MongoLogId, context: string, message: string): void { + this.log("info", id, context, message); + } + + error(id: MongoLogId, context: string, message: string): void { + this.log("error", id, context, message); + } + debug(id: MongoLogId, context: string, message: string): void { + this.log("debug", id, context, message); + } + + notice(id: MongoLogId, context: string, message: string): void { + this.log("notice", id, context, message); + } + + warning(id: MongoLogId, context: string, message: string): void { + this.log("warning", id, context, message); + } + + critical(id: MongoLogId, context: string, message: string): void { + this.log("critical", id, context, message); + } + + alert(id: MongoLogId, context: string, message: string): void { + this.log("alert", id, context, message); + } + + emergency(id: MongoLogId, context: string, message: string): void { + this.log("emergency", id, context, message); + } +} + +class ConsoleLogger extends LoggerBase { + log(level: LogLevel, id: MongoLogId, context: string, message: string): void { + message = redact(message); + console.error(`[${level.toUpperCase()}] ${id} - ${context}: ${message}`); + } +} + +class Logger extends LoggerBase { + constructor( + private logWriter: MongoLogWriter, + private server: McpServer + ) { + super(); + } + + log(level: LogLevel, id: MongoLogId, context: string, message: string): void { + message = redact(message); + const mongoDBLevel = this.mapToMongoDBLogLevel(level); + this.logWriter[mongoDBLevel]("MONGODB-MCP", id, context, message); + this.server.server.sendLoggingMessage({ + level, + data: `[${context}]: ${message}`, + }); + } + + private mapToMongoDBLogLevel(level: LogLevel): "info" | "warn" | "error" | "debug" | "fatal" { + switch (level) { + case "info": + return "info"; + case "warning": + return "warn"; + case "error": + return "error"; + case "notice": + case "debug": + return "debug"; + case "critical": + case "alert": + case "emergency": + return "fatal"; + default: + return "info"; + } + } +} + +class ProxyingLogger extends LoggerBase { + private internalLogger: LoggerBase = new ConsoleLogger(); + + log(level: LogLevel, id: MongoLogId, context: string, message: string): void { + this.internalLogger.log(level, id, context, message); + } +} + +const logger = new ProxyingLogger(); +export default logger; + +export async function initializeLogger(server: McpServer): Promise { + const logDir = path.join(config.localDataPath, ".app-logs"); + await fs.mkdir(logDir, { recursive: true }); + + const manager = new MongoLogManager({ + directory: path.join(config.localDataPath, ".app-logs"), + retentionDays: 30, + onwarn: console.warn, + onerror: console.error, + gzip: false, + retentionGB: 1, + }); + + await manager.cleanupOldLogFiles(); + + const logWriter = await manager.createLogWriter(); + logger["internalLogger"] = new Logger(logWriter, server); } diff --git a/src/server.ts b/src/server.ts index c9e9a662..789d223a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,8 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { registerAtlasTools } from "./tools/atlas/tools.js"; import { registerMongoDBTools } from "./tools/mongodb/index.js"; import { config } from "./config.js"; +import logger, { initializeLogger } from "./logger.js"; +import { mongoLogId } from "mongodb-log-writer"; export class Server { state: State | undefined = undefined; @@ -39,6 +41,8 @@ export class Server { version: config.version, }); + server.server.registerCapabilities({ logging: {} }); + registerAtlasTools(server, this.state!, this.apiClient!); registerMongoDBTools(server, this.state!); @@ -49,5 +53,8 @@ export class Server { await this.init(); const server = this.createMcpServer(); await server.connect(transport); + await initializeLogger(server); + + logger.info(mongoLogId(1_000_004), "server", `Server started with transport ${transport.constructor.name}`); } } diff --git a/src/tools/atlas/auth.ts b/src/tools/atlas/auth.ts index b53ae7d9..e6964a11 100644 --- a/src/tools/atlas/auth.ts +++ b/src/tools/atlas/auth.ts @@ -1,8 +1,9 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { log } from "../../logger.js"; import { saveState } from "../../state.js"; import { AtlasToolBase } from "./atlasTool.js"; import { isAuthenticated } from "../../common/atlas/auth.js"; +import logger from "../../logger.js"; +import { mongoLogId } from "mongodb-log-writer"; export class AuthTool extends AtlasToolBase { protected name = "atlas-auth"; @@ -15,7 +16,7 @@ export class AuthTool extends AtlasToolBase { async execute(): Promise { if (await this.isAuthenticated()) { - log("INFO", "Already authenticated!"); + logger.debug(mongoLogId(1_000_001), "auth", "Already authenticated!"); return { content: [{ type: "text", text: "You are already authenticated!" }], }; @@ -40,13 +41,13 @@ export class AuthTool extends AtlasToolBase { }; } catch (error: unknown) { if (error instanceof Error) { - log("error", `Authentication error: ${error}`); + logger.error(mongoLogId(1_000_002), "auth", `Authentication error: ${error}`); return { content: [{ type: "text", text: `Authentication failed: ${error.message}` }], }; } - log("error", `Unknown authentication error: ${error}`); + logger.error(mongoLogId(1_000_003), "auth", `Unknown authentication error: ${error}`); return { content: [{ type: "text", text: "Authentication failed due to an unknown error." }], }; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 7376c3ce..c8a2d60a 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -1,8 +1,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z, ZodNever, ZodRawShape } from "zod"; -import { log } from "../logger.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { State } from "../state.js"; +import logger from "../logger.js"; +import { mongoLogId } from "mongodb-log-writer"; export type ToolArgs = z.objectOutputType; @@ -21,10 +22,15 @@ export abstract class ToolBase { const callback = async (args: ToolArgs): Promise => { try { // TODO: add telemetry here + logger.debug( + mongoLogId(1_000_006), + "tool", + `Executing ${this.name} with args: ${JSON.stringify(args)}` + ); return await this.execute(args); } catch (error) { - log("error", `Error executing ${this.name}: ${error}`); + logger.error(mongoLogId(1_000_000), "tool", `Error executing ${this.name}: ${error}`); // If the error is authentication related, suggest using auth tool if (error instanceof Error && error.message.includes("Not authenticated")) {