diff --git a/biome.jsonc b/biome.jsonc index eed9b39..f29d39a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -17,7 +17,7 @@ "linter": { "rules": { "recommended": true, - "suspicious": { "noExplicitAny": "error" }, + "suspicious": { "noExplicitAny": "error", "noConsole": "warn" }, "performance": { "noAccumulatingSpread": "error", "recommended": true diff --git a/package.json b/package.json index ad0475a..55b9760 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "build:types": "bun run --filter @fiberplane/mcp-gateway build:types", "test": "bun test", "typecheck": "bun run --filter '*' typecheck", - "lint": "biome check .", - "format": "biome format --write .", + "lint": "biome ci .", + "format": "biome check --write .", "changeset": "changeset", "release": "changeset publish" }, diff --git a/packages/mcp-gateway/package.json b/packages/mcp-gateway/package.json index 3707a68..755a07b 100644 --- a/packages/mcp-gateway/package.json +++ b/packages/mcp-gateway/package.json @@ -19,11 +19,12 @@ "sideEffects": false, "scripts": { "dev": "bun build ./src/run.ts --outdir dist --root src --sourcemap=inline --target node --format esm --watch", + "dev:server": "LOG_LEVEL=debug bun run src/server --watch", "build": "bun run scripts/build.ts", "test": "bun test", "typecheck": "tsc --noEmit", - "lint": "biome check --write .", - "format": "biome format --write .", + "lint": "biome ci .", + "format": "biome check --write .", "prepublishOnly": "bun run build" }, "exports": { diff --git a/packages/mcp-gateway/src/capture.ts b/packages/mcp-gateway/src/capture.ts index cb8f7b7..eaa1018 100644 --- a/packages/mcp-gateway/src/capture.ts +++ b/packages/mcp-gateway/src/capture.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "./logger.js"; import type { CaptureRecord, ClientInfo, @@ -76,7 +77,7 @@ export function createRequestCaptureRecord( // Validate the record const result = captureRecordSchema.safeParse(record); if (!result.success) { - console.warn("Invalid capture record:", result.error); + logger.warn("Invalid capture record", { error: result.error }); throw new Error("Failed to create valid capture record"); } @@ -121,7 +122,7 @@ export function createResponseCaptureRecord( // Validate the record const result = captureRecordSchema.safeParse(record); if (!result.success) { - console.warn("Invalid capture record:", result.error); + logger.warn("Invalid capture record", { error: result.error }); throw new Error("Failed to create valid capture record"); } @@ -132,17 +133,19 @@ export async function appendCapture( storageDir: string, record: CaptureRecord, ): Promise { - try { - // Ensure server capture directory exists - await ensureServerCaptureDir(storageDir, record.metadata.serverName); + // Generate filename (one per session) + let filePath: string = "unknown"; - // Generate filename (one per session) + try { const filename = generateCaptureFilename( record.metadata.serverName, record.metadata.sessionId, ); - const filePath = join(storageDir, record.metadata.serverName, filename); + filePath = join(storageDir, record.metadata.serverName, filename); + + // Ensure server capture directory exists + await ensureServerCaptureDir(storageDir, record.metadata.serverName); // Append JSONL record to file const jsonLine = `${JSON.stringify(record)}\n`; @@ -159,8 +162,19 @@ export async function appendCapture( await writeFile(filePath, existingContent + jsonLine, "utf8"); return filename; } catch (error) { - console.error("Failed to append capture record:", error); - throw new Error(`Capture storage failed: ${error}`); + logger.error("Failed to append capture record", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + } + : String(error), + filePath, + }); + throw new Error( + `Capture storage failed: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -233,7 +247,7 @@ export function createSSEEventCaptureRecord( // Validate the record const result = captureRecordSchema.safeParse(record); if (!result.success) { - console.warn("Invalid SSE capture record:", result.error); + logger.warn("Invalid SSE capture record", { error: result.error }); throw new Error("Failed to create valid SSE capture record"); } @@ -315,7 +329,7 @@ export function createSSEJsonRpcCaptureRecord( // Validate the record const result = captureRecordSchema.safeParse(record); if (!result.success) { - console.warn("Invalid SSE JSON-RPC capture record:", result.error); + logger.warn("Invalid SSE JSON-RPC capture record", { error: result.error }); throw new Error("Failed to create valid SSE JSON-RPC capture record"); } @@ -341,7 +355,7 @@ export async function captureSSEEvent( ); await appendCapture(storageDir, record); } catch (error) { - console.error("Failed to capture SSE event:", error); + logger.error("Failed to capture SSE event", { error: String(error) }); // Don't throw - SSE capture failures shouldn't break streaming } } @@ -366,7 +380,7 @@ export async function captureSSEJsonRpc( await appendCapture(storageDir, record); return record; } catch (error) { - console.error("Failed to capture SSE JSON-RPC:", error); + logger.error("Failed to capture SSE JSON-RPC", { error: String(error) }); // Don't throw - SSE capture failures shouldn't break streaming return null; } diff --git a/packages/mcp-gateway/src/logger.ts b/packages/mcp-gateway/src/logger.ts new file mode 100644 index 0000000..f0cfe36 --- /dev/null +++ b/packages/mcp-gateway/src/logger.ts @@ -0,0 +1,208 @@ +import { appendFile, readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { ensureStorageDir } from "./storage.js"; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + context?: Record; +} + +class Logger { + private storageDir: string | null = null; + private currentDate: string | null = null; + private logDir: string | null = null; + private minLevel: LogLevel = "info"; // Default: skip debug logs + + /** + * Initialize the logger with the storage directory + */ + async initialize(storageDir: string): Promise { + this.storageDir = storageDir; + this.logDir = join(storageDir, "logs"); + this.currentDate = this.getDateString(); + + // Set minimum log level from environment variable + const envLevel = process.env.LOG_LEVEL?.toLowerCase(); + if ( + envLevel === "debug" || + envLevel === "info" || + envLevel === "warn" || + envLevel === "error" + ) { + this.minLevel = envLevel; + } + + // Ensure log directory exists + await ensureStorageDir(this.logDir); + + // Clean up old logs (non-blocking) + this.cleanupOldLogs().catch((error) => { + // Silently fail - don't block initialization + // biome-ignore lint/suspicious/noConsole: actually want to print to console + console.error("Failed to cleanup old logs:", error); + }); + } + + /** + * Check if a log level should be written based on minimum level + */ + private shouldLog(level: LogLevel): boolean { + const levels: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + }; + return levels[level] >= levels[this.minLevel]; + } + + /** + * Get current date string in YYYY-MM-DD format + */ + private getDateString(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + /** + * Get the current log file path + */ + private getLogFilePath(): string { + if (!this.logDir) { + throw new Error("Logger not initialized. Call initialize() first."); + } + + const currentDate = this.getDateString(); + + // Check if we need to rotate to a new day + if (this.currentDate !== currentDate) { + this.currentDate = currentDate; + } + + return join(this.logDir, `gateway-${currentDate}.log`); + } + + /** + * Write a log entry to file + */ + private async writeLog( + level: LogLevel, + message: string, + context?: Record, + ): Promise { + if (!this.storageDir) { + // Logger not initialized, silently skip + return; + } + + // Check if this log level should be written + if (!this.shouldLog(level)) { + return; + } + + try { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + ...(context && Object.keys(context).length > 0 ? { context } : {}), + }; + + const logLine = `${JSON.stringify(entry)}\n`; + const logFile = this.getLogFilePath(); + + await appendFile(logFile, logLine, "utf-8"); + } catch (error) { + // Silently fail - don't throw errors from logger + // HACK - Don't log errors in test environment (this was a quickfix, sorry) + if (!this.isTestEnvironment()) { + // biome-ignore lint/suspicious/noConsole: actually want to print to console + console.error("Failed to write log:", error); + } + } + } + + private isTestEnvironment(): boolean { + return ( + process.env.NODE_ENV === "test" || + process.env.BTEST === "1" || // Bun sets this during tests + (typeof Bun !== "undefined" && Bun.env.TEST === "true") + ); + } + + /** + * Log a debug message + */ + debug(message: string, context?: Record): void { + this.writeLog("debug", message, context).catch(() => { + // Ignore errors + }); + } + + /** + * Log an info message + */ + info(message: string, context?: Record): void { + this.writeLog("info", message, context).catch(() => { + // Ignore errors + }); + } + + /** + * Log a warning message + */ + warn(message: string, context?: Record): void { + this.writeLog("warn", message, context).catch(() => { + // Ignore errors + }); + } + + /** + * Log an error message + */ + error(message: string, context?: Record): void { + this.writeLog("error", message, context).catch(() => { + // Ignore errors + }); + } + + /** + * Clean up log files older than 30 days + */ + private async cleanupOldLogs(): Promise { + if (!this.logDir) { + return; + } + + try { + const files = await readdir(this.logDir); + const now = Date.now(); + const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; + + for (const file of files) { + if (!file.startsWith("gateway-") || !file.endsWith(".log")) { + continue; + } + + const filePath = join(this.logDir, file); + const stats = await stat(filePath); + + if (stats.mtimeMs < thirtyDaysAgo) { + await unlink(filePath); + } + } + } catch (_error) { + // Silently fail - cleanup is not critical + } + } +} + +// Export singleton instance +export const logger = new Logger(); diff --git a/packages/mcp-gateway/src/mcp-server.ts b/packages/mcp-gateway/src/mcp-server.ts index a5ec105..c0aab72 100644 --- a/packages/mcp-gateway/src/mcp-server.ts +++ b/packages/mcp-gateway/src/mcp-server.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { McpServer, RpcError, StreamableHttpTransport } from "mcp-lite"; import { z } from "zod"; +import { logger } from "./logger.js"; import { createCaptureTools } from "./mcp-tools/capture-tools.js"; import { createServerTools } from "./mcp-tools/server-tools.js"; import type { Registry } from "./registry.js"; @@ -29,12 +30,18 @@ export function createMcpServer( // Add request logging middleware mcp.use(async (ctx, next) => { const startTime = Date.now(); - console.log(`[MCP] ${ctx.request.method} - Request ID: ${ctx.requestId}`); + logger.debug("MCP request started", { + method: ctx.request.method, + requestId: ctx.requestId, + }); await next(); const duration = Date.now() - startTime; - console.log(`[MCP] ${ctx.request.method} completed in ${duration}ms`); + logger.debug("MCP request completed", { + method: ctx.request.method, + duration, + }); }); // Add error handling middleware @@ -42,7 +49,10 @@ export function createMcpServer( try { await next(); } catch (error) { - console.error(`[MCP] Error in ${ctx.request.method}:`, error); + logger.error("MCP request error", { + method: ctx.request.method, + error: String(error), + }); throw error; } }); @@ -55,10 +65,10 @@ export function createMcpServer( // Set up custom error handler mcp.onError((error, ctx) => { - console.error( - `[MCP] Error handler called for ${ctx.request.method}:`, - error, - ); + logger.error("MCP error handler called", { + method: ctx.request.method, + error: String(error), + }); // Handle RpcError instances from mcp-lite (e.g., validation errors) if (error instanceof RpcError) { diff --git a/packages/mcp-gateway/src/mcp-tools/capture-tools.ts b/packages/mcp-gateway/src/mcp-tools/capture-tools.ts index 305999f..c8a89fe 100644 --- a/packages/mcp-gateway/src/mcp-tools/capture-tools.ts +++ b/packages/mcp-gateway/src/mcp-tools/capture-tools.ts @@ -2,6 +2,7 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import type { McpServer } from "mcp-lite"; import { z } from "zod"; +import { logger } from "../logger.js"; import type { Registry } from "../registry.js"; import type { CaptureRecord } from "../schemas.js"; @@ -158,11 +159,14 @@ async function findCaptureFiles( }); } } catch (error) { - console.warn(`Failed to read server directory ${serverName}:`, error); + logger.warn("Failed to read server directory", { + serverName, + error: String(error), + }); } } } catch (error) { - console.warn("Failed to read storage directory:", error); + logger.warn("Failed to read storage directory", { error: String(error) }); } return captureFiles.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); @@ -185,13 +189,19 @@ async function parseJsonlFile(filePath: string): Promise { const record = JSON.parse(line) as CaptureRecord; records.push(record); } catch (error) { - console.warn(`Failed to parse JSONL line in ${filePath}:`, error); + logger.warn("Failed to parse JSONL line", { + filePath, + error: String(error), + }); } } return records; } catch (error) { - console.warn(`Failed to read capture file ${filePath}:`, error); + logger.warn("Failed to read capture file", { + filePath, + error: String(error), + }); return []; } } diff --git a/packages/mcp-gateway/src/run.ts b/packages/mcp-gateway/src/run.ts index f6d73c4..5a1be3b 100644 --- a/packages/mcp-gateway/src/run.ts +++ b/packages/mcp-gateway/src/run.ts @@ -4,12 +4,14 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { serve } from "@hono/node-server"; import { startHealthChecks } from "./health.js"; +import { logger } from "./logger.js"; import { createApp } from "./server/index.js"; import { getStorageRoot, loadRegistry } from "./storage.js"; import { runTUI } from "./tui/loop.js"; import type { Context } from "./tui/state.js"; function showHelp(): void { + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log(` Usage: mcp-gateway [options] @@ -36,6 +38,7 @@ function showVersion(): void { const __dirname = dirname(__filename); const packageJsonPath = join(__dirname, "../package.json"); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log(`mcp-gateway v${packageJson.version}`); } @@ -75,6 +78,9 @@ export async function runCli(): Promise { // Get storage directory const storageDir = getStorageRoot(values["storage-dir"]); + // Initialize logger + await logger.initialize(storageDir); + // Load registry once and share it between server and CLI const registry = await loadRegistry(storageDir); @@ -87,6 +93,7 @@ export async function runCli(): Promise { port, }); + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log(`MCP Gateway server started at http://localhost:${port}`); // Start health checks @@ -104,22 +111,25 @@ export async function runCli(): Promise { // Start TUI only if running in a TTY if (process.stdin.isTTY) { runTUI(context, registry).catch((error) => { - console.error("TUI error:", error); + logger.error("TUI error", { error: String(error) }); stopHealthChecks(); server.close(); process.exit(1); }); } else { + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log( "Running in headless mode (no TTY detected). Server will run until terminated.", ); // Keep process alive and handle signals process.on("SIGTERM", () => { + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log("\nReceived SIGTERM, shutting down..."); context.onExit?.(); process.exit(0); }); process.on("SIGINT", () => { + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log("\nReceived SIGINT, shutting down..."); context.onExit?.(); process.exit(0); @@ -127,9 +137,20 @@ export async function runCli(): Promise { } } catch (error) { if (error instanceof Error) { - console.error(`Error: ${error.message}`); + // print error message to user + // biome-ignore lint/suspicious/noConsole: actually want to print to console + console.error("CLI error:", error.message); + + // Also log the error message and stack to the log files + logger.error("CLI error", { + message: error.message, + stack: error.stack, + }); } - console.error("Run with --help for usage information."); + + // print message to user on how to look up usage + // biome-ignore lint/suspicious/noConsole: actually want to print to console + console.error("Run with --help for usage information"); process.exit(1); } } diff --git a/packages/mcp-gateway/src/server/create-proxy-routes.ts b/packages/mcp-gateway/src/server/create-proxy-routes.ts index e3c630a..73e142f 100644 --- a/packages/mcp-gateway/src/server/create-proxy-routes.ts +++ b/packages/mcp-gateway/src/server/create-proxy-routes.ts @@ -15,6 +15,7 @@ import { getClientInfo, storeClientInfo, } from "../capture.js"; +import { logger } from "../logger.js"; import { getServer, type McpServer, type Registry } from "../registry.js"; import { type CaptureRecord, @@ -156,7 +157,11 @@ async function handleSessionTransition( try { await rename(oldPath, newPath); } catch (error) { - console.warn(`Failed to rename capture file: ${error}`); + logger.warn("Failed to rename capture file", { + error: String(error), + oldPath, + newPath, + }); } } } @@ -593,7 +598,11 @@ async function processSSECapture( } } } catch (error) { - console.error(`${server.name} → ${method} (SSE capture error):`, error); + logger.error("SSE capture error", { + server: server.name, + method, + error: String(error), + }); // Don't throw - capture failures shouldn't affect the client stream } } diff --git a/packages/mcp-gateway/src/server/create-server.ts b/packages/mcp-gateway/src/server/create-server.ts index b838782..98a25c3 100644 --- a/packages/mcp-gateway/src/server/create-server.ts +++ b/packages/mcp-gateway/src/server/create-server.ts @@ -1,4 +1,6 @@ import { Hono } from "hono"; +import { logger as loggerMiddleware } from "hono/logger"; +import { logger } from "../logger.js"; import { createMcpApp } from "../mcp-server.js"; import type { Registry } from "../registry.js"; import { getStorageRoot } from "../storage.js"; @@ -12,6 +14,17 @@ export async function createApp( ): Promise<{ app: Hono; registry: Registry }> { const app = new Hono(); + // Custom Hono logger middleware to log to our log files + app.use( + loggerMiddleware((message: string, ...rest: string[]) => { + if (rest.length > 0) { + logger.debug(message, { honoLoggerArgs: rest }); + } else { + logger.debug(message); + } + }), + ); + // Determine storage directory const storage = getStorageRoot(storageDir); diff --git a/packages/mcp-gateway/src/server/index.ts b/packages/mcp-gateway/src/server/index.ts index cbeaf58..df4fc2a 100644 --- a/packages/mcp-gateway/src/server/index.ts +++ b/packages/mcp-gateway/src/server/index.ts @@ -1,3 +1,4 @@ +import { logger } from "../logger.js"; import { getStorageRoot, loadRegistry } from "../storage.js"; import { createApp } from "./create-server.js"; @@ -5,10 +6,15 @@ import { createApp } from "./create-server.js"; export { createApp }; // Create app instance for development -const devRegistry = await loadRegistry(getStorageRoot()); -const { app } = await createApp(devRegistry, getStorageRoot()); +const storageDir = getStorageRoot(); +const devRegistry = await loadRegistry(storageDir); +const { app } = await createApp(devRegistry, storageDir); const port = 3333; +if (import.meta.main) { + await logger.initialize(storageDir); +} + export default { port, fetch: app.fetch, diff --git a/packages/mcp-gateway/src/storage.ts b/packages/mcp-gateway/src/storage.ts index 3666670..cfb3127 100644 --- a/packages/mcp-gateway/src/storage.ts +++ b/packages/mcp-gateway/src/storage.ts @@ -2,6 +2,7 @@ import { constants } from "node:fs"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { logger } from "./logger.js"; import type { Registry } from "./registry.js"; import { fromMcpJson, toMcpJson } from "./registry.js"; @@ -42,9 +43,9 @@ export async function loadRegistry(storageDir: string): Promise { const data = JSON.parse(content); return fromMcpJson(data); } catch (_error) { - console.warn( - `Warning: Invalid mcp.json at ${mcpPath}, starting with empty registry`, - ); + logger.warn("Invalid mcp.json, starting with empty registry", { + path: mcpPath, + }); return { servers: [] }; } } diff --git a/packages/mcp-gateway/src/tui/effects.ts b/packages/mcp-gateway/src/tui/effects.ts index bcd7e9c..406b3f2 100644 --- a/packages/mcp-gateway/src/tui/effects.ts +++ b/packages/mcp-gateway/src/tui/effects.ts @@ -70,8 +70,6 @@ export async function performEffect( // Check if server already exists if (state.registry.servers.some((s) => s.name === normalizedName)) { - console.log(`\nError: Server '${effect.name}' already exists`); - await new Promise((resolve) => setTimeout(resolve, 1500)); return; } @@ -94,15 +92,8 @@ export async function performEffect( // Emit update so UI refreshes with new server emitRegistryUpdate(); - - // Show success message briefly - console.log(`\nāœ“ Server '${effect.name}' added successfully!`); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch (error) { - console.log( - `\nError: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (_error) { + // Silently fail - error will be reflected in UI state } break; } @@ -114,8 +105,6 @@ export async function performEffect( ); if (serverIndex === -1) { - console.log(`\nError: Server '${effect.serverName}' not found`); - await new Promise((resolve) => setTimeout(resolve, 1500)); return; } @@ -131,16 +120,8 @@ export async function performEffect( // Emit update so UI refreshes with removed server emitRegistryUpdate(); - - // Show success message briefly - console.log(`\nāœ“ Server '${effect.serverName}' removed successfully!`); - console.log(`Note: Capture history preserved on disk`); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch (error) { - console.log( - `\nError: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (_error) { + // Silently fail - error will be reflected in UI state } break; } diff --git a/packages/mcp-gateway/src/tui/loop.ts b/packages/mcp-gateway/src/tui/loop.ts index 43b4ea1..1f85c34 100644 --- a/packages/mcp-gateway/src/tui/loop.ts +++ b/packages/mcp-gateway/src/tui/loop.ts @@ -334,6 +334,7 @@ export async function runTUI( ): Promise { // Guard against non-TTY environments if (!process.stdin.isTTY || !process.stdin.setRawMode) { + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log("TUI requires a TTY environment. Run in headless mode."); return; } @@ -410,6 +411,7 @@ export async function runTUI( if (!state.running) { process.stdin.removeListener("data", stdinHandler); cleanup(); + // biome-ignore lint/suspicious/noConsole: actually want to print to console console.log("Closing the MCP Gateway..."); context.onExit?.(); process.exit(0); diff --git a/packages/mcp-gateway/tests/logger.test.ts b/packages/mcp-gateway/tests/logger.test.ts new file mode 100644 index 0000000..49e5157 --- /dev/null +++ b/packages/mcp-gateway/tests/logger.test.ts @@ -0,0 +1,464 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: tests */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { logger } from "../src/logger.js"; + +let tempDir: string; + +// Helper to reset logger state +function resetLogger() { + // @ts-expect-error - accessing private property for testing + logger.storageDir = null; + // @ts-expect-error - accessing private property for testing + logger.logDir = null; + // @ts-expect-error - accessing private property for testing + logger.minLevel = "info"; + // @ts-expect-error - accessing private property for testing + logger.currentDate = null; + delete process.env.LOG_LEVEL; +} + +beforeEach(async () => { + resetLogger(); + tempDir = await mkdtemp(join(tmpdir(), "mcp-logger-test-")); +}); + +afterEach(async () => { + resetLogger(); + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +describe("Logger Initialization", () => { + test("should create logs directory on initialization", async () => { + await logger.initialize(tempDir); + + const logsDir = join(tempDir, "logs"); + const dirStats = await stat(logsDir); + expect(dirStats.isDirectory()).toBe(true); + }); + + test("should set default minimum level to info", async () => { + await logger.initialize(tempDir); + + // @ts-expect-error - accessing private property for testing + expect(logger.minLevel).toBe("info"); + }); + + test("should respect LOG_LEVEL environment variable", async () => { + process.env.LOG_LEVEL = "debug"; + await logger.initialize(tempDir); + + // @ts-expect-error - accessing private property for testing + expect(logger.minLevel).toBe("debug"); + }); + + test("should handle invalid LOG_LEVEL gracefully", async () => { + process.env.LOG_LEVEL = "invalid"; + await logger.initialize(tempDir); + + // @ts-expect-error - accessing private property for testing + expect(logger.minLevel).toBe("info"); // Should remain default + }); + + test("should accept all valid log levels from environment", async () => { + const validLevels = ["debug", "info", "warn", "error"]; + + for (const level of validLevels) { + // Reset environment and logger state for each iteration + resetLogger(); + + process.env.LOG_LEVEL = level; + await logger.initialize(tempDir); + + // @ts-expect-error - accessing private property for testing + expect(logger.minLevel).toBe(level); + } + }); + + test("should handle case-insensitive LOG_LEVEL", async () => { + process.env.LOG_LEVEL = "DEBUG"; + await logger.initialize(tempDir); + + // @ts-expect-error - accessing private property for testing + expect(logger.minLevel).toBe("debug"); + }); +}); + +describe("Log Level Filtering", () => { + test.serial("should skip debug logs when minLevel is info", async () => { + await logger.initialize(tempDir); + + logger.debug("This should not be written"); + logger.info("This should be written"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + expect(files.length).toBe(1); + + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + expect(lines.length).toBe(1); // Only info log + const entry = JSON.parse(lines[0]!); + expect(entry.level).toBe("info"); + expect(entry.message).toBe("This should be written"); + }); + + test.serial("should write all logs when minLevel is debug", async () => { + // Create a fresh temp directory for this test + const testTempDir = await mkdtemp(join(tmpdir(), "mcp-logger-debug-test-")); + try { + process.env.LOG_LEVEL = "debug"; + await logger.initialize(testTempDir); + + logger.debug("Debug message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.info("Info message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.warn("Warn message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.error("Error message"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(testTempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + expect(lines.length).toBe(4); + expect(JSON.parse(lines[0]!).level).toBe("debug"); + expect(JSON.parse(lines[1]!).level).toBe("info"); + expect(JSON.parse(lines[2]!).level).toBe("warn"); + expect(JSON.parse(lines[3]!).level).toBe("error"); + } finally { + await rm(testTempDir, { recursive: true, force: true }); + } + }); + + test.serial( + "should only write warn and error when minLevel is warn", + async () => { + const testTempDir = await mkdtemp( + join(tmpdir(), "mcp-logger-warn-test-"), + ); + try { + process.env.LOG_LEVEL = "warn"; + await logger.initialize(testTempDir); + + logger.debug("Debug message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.info("Info message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.warn("Warn message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.error("Error message"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(testTempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0]!).level).toBe("warn"); + expect(JSON.parse(lines[1]!).level).toBe("error"); + } finally { + await rm(testTempDir, { recursive: true, force: true }); + } + }, + ); + + test.serial("should only write error when minLevel is error", async () => { + process.env.LOG_LEVEL = "error"; + await logger.initialize(tempDir); + + logger.debug("Debug message"); + logger.info("Info message"); + logger.warn("Warn message"); + logger.error("Error message"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + expect(lines.length).toBe(1); + expect(JSON.parse(lines[0]!).level).toBe("error"); + }); +}); + +describe("Log Writing", () => { + test.serial("should write log entries as JSON lines", async () => { + await logger.initialize(tempDir); + + logger.info("Test message"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + expect(files.length).toBe(1); + + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + expect(lines.length).toBe(1); + const entry = JSON.parse(lines[0]!); + + expect(entry).toHaveProperty("timestamp"); + expect(entry).toHaveProperty("level"); + expect(entry).toHaveProperty("message"); + expect(entry.level).toBe("info"); + expect(entry.message).toBe("Test message"); + }); + + test.serial("should include context object when provided", async () => { + await logger.initialize(tempDir); + + logger.info("Test with context", { userId: "123", action: "login" }); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const entry = JSON.parse(logContent.trim()); + + expect(entry.context).toEqual({ userId: "123", action: "login" }); + }); + + test.serial( + "should not include context field when context is empty", + async () => { + await logger.initialize(tempDir); + + logger.info("Test without context"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const entry = JSON.parse(logContent.trim()); + + expect(entry).not.toHaveProperty("context"); + }, + ); + + test.serial("should write timestamp in ISO 8601 format", async () => { + await logger.initialize(tempDir); + + logger.info("Test timestamp"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const entry = JSON.parse(logContent.trim()); + + // ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ + expect(entry.timestamp).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ); + }); + + test.serial("should append multiple log entries to same file", async () => { + // Create a fresh temp directory for this test + const testTempDir = await mkdtemp( + join(tmpdir(), "mcp-logger-append-test-"), + ); + try { + await logger.initialize(testTempDir); + + logger.info("First message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.warn("Second message"); + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.error("Third message"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(testTempDir, "logs"); + const files = await readdir(logsDir); + expect(files.length).toBe(1); + + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + expect(lines.length).toBe(3); + expect(JSON.parse(lines[0]!).message).toBe("First message"); + expect(JSON.parse(lines[1]!).message).toBe("Second message"); + expect(JSON.parse(lines[2]!).message).toBe("Third message"); + } finally { + await rm(testTempDir, { recursive: true, force: true }); + } + }); +}); + +describe("Daily Log Rotation", () => { + test.serial( + "should create log file with current date in filename", + async () => { + await logger.initialize(tempDir); + + logger.info("Test message"); + + // Wait for async file writes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const expectedFilename = `gateway-${year}-${month}-${day}.log`; + + expect(files).toContain(expectedFilename); + }, + ); + + test("should handle logging without initialization gracefully", async () => { + // Don't initialize logger + logger.info("This should not crash"); + logger.error("This should also not crash"); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)); + + // No logs directory should exist + const logsDir = join(tempDir, "logs"); + try { + await stat(logsDir); + expect(true).toBe(false); // Should not reach here + } catch (error) { + // Expected - directory should not exist + expect(error).toBeDefined(); + } + }); +}); + +describe("Log Cleanup", () => { + test("should delete log files older than 30 days", async () => { + await logger.initialize(tempDir); + + const logsDir = join(tempDir, "logs"); + + // Create old log files + const thirtyOneDaysAgo = new Date(); + thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31); + const oldDate = thirtyOneDaysAgo.toISOString().split("T")[0]; + const oldLogFile = join(logsDir, `gateway-${oldDate}.log`); + await writeFile(oldLogFile, "old log content\n", "utf-8"); + + // Create recent log file + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const recentDate = yesterday.toISOString().split("T")[0]; + const recentLogFile = join(logsDir, `gateway-${recentDate}.log`); + await writeFile(recentLogFile, "recent log content\n", "utf-8"); + + // Note: We can't easily set mtime in tests, so we'll just verify the file exists for now + // In real usage, files with old mtimes (>30 days) will be deleted by the cleanup function + + // Wait for cleanup to run (it runs on initialization) + await new Promise((resolve) => setTimeout(resolve, 200)); + + const files = await readdir(logsDir); + + // The old file should still exist in this test because we can't mock file times easily + // In real usage, files with old mtimes will be deleted + expect(files.length).toBeGreaterThan(0); + }); + + test("should only delete files matching gateway-*.log pattern", async () => { + await logger.initialize(tempDir); + + const logsDir = join(tempDir, "logs"); + + // Create files that should NOT be deleted + await writeFile(join(logsDir, "other.log"), "content\n", "utf-8"); + await writeFile(join(logsDir, "gateway.txt"), "content\n", "utf-8"); + await writeFile( + join(logsDir, "not-gateway-2025-01-01.log"), + "content\n", + "utf-8", + ); + + // Wait for cleanup to run + await new Promise((resolve) => setTimeout(resolve, 200)); + + const files = await readdir(logsDir); + + // These files should still exist + expect(files).toContain("other.log"); + expect(files).toContain("gateway.txt"); + expect(files).toContain("not-gateway-2025-01-01.log"); + }); +}); + +describe("Error Handling", () => { + test("should not throw when writing to invalid path", async () => { + // Initialize with valid path first + await logger.initialize(tempDir); + + // Manually set invalid log dir to test error handling + // @ts-expect-error - accessing private property for testing + logger.logDir = "/invalid/path/that/does/not/exist"; + + // These should not throw + expect(() => { + logger.info("Test message"); + logger.error("Test error"); + }).not.toThrow(); + }); + + test.serial("should handle concurrent writes gracefully", async () => { + await logger.initialize(tempDir); + + // Write many logs concurrently + const promises = []; + for (let i = 0; i < 50; i++) { + promises.push(logger.info(`Message ${i}`, { index: i })); + } + + await Promise.all(promises); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const logsDir = join(tempDir, "logs"); + const files = await readdir(logsDir); + const logContent = await readFile(join(logsDir, files[0]!), "utf-8"); + const lines = logContent.trim().split("\n"); + + // All messages should be written + expect(lines.length).toBe(50); + }); +}); diff --git a/packages/mcp-gateway/tests/proxy/auth-401s.test.ts b/packages/mcp-gateway/tests/proxy/auth-401s.test.ts index 00ff307..ad256fc 100644 --- a/packages/mcp-gateway/tests/proxy/auth-401s.test.ts +++ b/packages/mcp-gateway/tests/proxy/auth-401s.test.ts @@ -1,3 +1,5 @@ +/** biome-ignore-all lint/suspicious/noConsole: tests */ + import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/mcp-gateway/tests/proxy/oauth-routes.test.ts b/packages/mcp-gateway/tests/proxy/oauth-routes.test.ts index dfaf5f1..d06cf37 100644 --- a/packages/mcp-gateway/tests/proxy/oauth-routes.test.ts +++ b/packages/mcp-gateway/tests/proxy/oauth-routes.test.ts @@ -1,3 +1,5 @@ +/** biome-ignore-all lint/suspicious/noConsole: tests */ + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/mcp-gateway/tests/proxy/proxy.test.ts b/packages/mcp-gateway/tests/proxy/proxy.test.ts index 5fc0974..338281c 100644 --- a/packages/mcp-gateway/tests/proxy/proxy.test.ts +++ b/packages/mcp-gateway/tests/proxy/proxy.test.ts @@ -1,3 +1,5 @@ +/** biome-ignore-all lint/suspicious/noConsole: tests */ + import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/mcp-gateway/tests/sse.test.ts b/packages/mcp-gateway/tests/sse.test.ts index 07ae3a2..a975d0d 100644 --- a/packages/mcp-gateway/tests/sse.test.ts +++ b/packages/mcp-gateway/tests/sse.test.ts @@ -1,3 +1,5 @@ +/** biome-ignore-all lint/suspicious/noConsole: tests */ + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os";