import { EventEmitter } from "events"; function safeClone(value) { try { return JSON.parse( JSON.stringify(value, (_, val) => (typeof val === "bigint" ? val.toString() : val)) ); } catch (error) { return { error: `Serialization failed: ${error.message}` }; } } function formatError(error) { if (!error) { return null; } if (error instanceof Error) { return { name: error.name, message: error.message, stack: error.stack, }; } return { name: "Error", message: typeof error === "string" ? error : JSON.stringify(error), }; } export class McpRequestMonitor extends EventEmitter { constructor(options = {}) { super(); const { maxEntries = 300 } = options; this.maxEntries = maxEntries; this.entries = []; this.nextId = 1; } start(method, request, extra = {}) { const clonedRequest = safeClone(request); let requestChars = 0; try { requestChars = JSON.stringify(clonedRequest).length; } catch {} const entry = { id: this.nextId++, method, timestamp: new Date().toISOString(), status: "pending", durationMs: null, sessionId: extra.sessionId || extra.requestInfo?.headers?.["mcp-session-id"] || null, requestHeaders: extra.requestInfo?.headers ? { ...extra.requestInfo.headers } : undefined, request: clonedRequest, response: null, error: null, toolName: method === "tools/call" ? request?.params?.name || null : null, requestChars, responseChars: 0, _startedAt: Date.now(), }; this.entries.push(entry); this.trimEntries(); this.emit("summary", this.toSummary(entry)); return entry; } finish(entry, response) { if (!entry) return; entry.status = "success"; entry.durationMs = Date.now() - entry._startedAt; entry.response = safeClone(response); try { entry.responseChars = JSON.stringify(entry.response).length; } catch {} this.emit("summary", this.toSummary(entry)); } fail(entry, error) { if (!entry) return; entry.status = "error"; entry.durationMs = Date.now() - entry._startedAt; entry.error = formatError(error); try { entry.responseChars = JSON.stringify(entry.error).length; } catch {} this.emit("summary", this.toSummary(entry)); } getSummaries() { return this.entries.map((entry) => this.toSummary(entry)); } getEntryById(id) { const numericId = typeof id === "number" ? id : Number(id); const entry = this.entries.find((item) => item.id === numericId); if (!entry) return null; const { _startedAt, ...rest } = entry; return rest; } toSummary(entry) { return { id: entry.id, method: entry.method, timestamp: entry.timestamp, status: entry.status, durationMs: entry.durationMs, sessionId: entry.sessionId, errorMessage: entry.error?.message || null, toolName: entry.toolName || null, requestChars: entry.requestChars || 0, responseChars: entry.responseChars || 0, }; } trimEntries() { if (this.entries.length <= this.maxEntries) { return; } this.entries.splice(0, this.entries.length - this.maxEntries); } getSessionStats() { const statsMap = {}; for (const entry of this.entries) { const sid = entry.sessionId; if (!sid) continue; if (!statsMap[sid]) { statsMap[sid] = { sessionId: sid, totalRequests: 0, totalToolCalls: 0, totalRequestChars: 0, totalResponseChars: 0, toolBreakdown: {}, errorCount: 0, }; } const stats = statsMap[sid]; stats.totalRequests++; stats.totalRequestChars += entry.requestChars || 0; stats.totalResponseChars += entry.responseChars || 0; if (entry.status === "error") stats.errorCount++; if (entry.toolName) { stats.totalToolCalls++; if (!stats.toolBreakdown[entry.toolName]) { stats.toolBreakdown[entry.toolName] = { count: 0, requestChars: 0, responseChars: 0, errors: 0, totalDurationMs: 0 }; } const tb = stats.toolBreakdown[entry.toolName]; tb.count++; tb.requestChars += entry.requestChars || 0; tb.responseChars += entry.responseChars || 0; if (entry.status === "error") tb.errors++; if (entry.durationMs != null) tb.totalDurationMs += entry.durationMs; } } for (const stats of Object.values(statsMap)) { for (const tb of Object.values(stats.toolBreakdown)) { tb.avgDurationMs = tb.count > 0 ? Math.round(tb.totalDurationMs / tb.count) : 0; delete tb.totalDurationMs; } } return Object.values(statsMap); } }