162 lines
5.3 KiB
JavaScript
162 lines
5.3 KiB
JavaScript
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);
|
|
}
|
|
}
|