@inso_web/els-mcp
Version:
MCP-сервер поверх INSO Error Logs Service. Read-only tools (search, analytics, fingerprinting, correlations) для подключения Claude Desktop/Code и ChatGPT к логам ошибок. Streamable HTTP transport + stdio для npx-запуска.
243 lines • 10.6 kB
JavaScript
/**
* Tool-handler middleware:
* 1. Quota check (`checkQuota`) перед основным запросом.
* 2. AI quota check (`checkAiQuota`) для AI-tools (см. `isAiTool`).
* 3. Audit log + usage tracker (fire-and-forget) после.
* 4. Redaction items в response (если ENV redactionEnabled = true).
* 5. Prometheus метрики (`mcp_requests_total`, `mcp_request_duration_seconds`,
* `mcp_errors_total`, `mcp_auth_rejections_total`).
*
* Не вносит изменений в существующие tool handlers — оборачивает их в
* registry.
*/
import { ToolError } from '../lib/errors.js';
import { redactErrorLogs, redactValue } from '../redaction/index.js';
import { getAuditService } from '../audit/service.js';
import { getUsageTracker } from '../billing/tracker.js';
import { isAiTool } from '../billing/limits.js';
import { recordToolRequest, recordToolError, recordAuthRejection, } from '../observability/metrics.js';
/**
* Оборачивает существующий tool handler.
*
* Семантика:
* - Если `quotaCheck`-result `allowed=false` → возвращаем ToolError('TIER_QUOTA_EXCEEDED').
* - При `overage=true` (grace) добавляем `_meta.overage=true` к ответу.
* - После handler-вызова прогоняем items / log через redaction.
* - Audit + usage пишутся fire-and-forget (void).
*/
export function withMiddleware(toolName, handler, getContext, deps = {}) {
return async (args, client) => {
const ctx = getContext(args, client);
const log = ctx.log;
const start = Date.now();
const audit = deps.audit ?? getAuditService();
const usage = deps.usage ?? getUsageTracker();
// 1. Per-day request quota.
let quota = null;
try {
quota = deps.quotaCheck
? await deps.quotaCheck(ctx)
: await usage.checkQuota(ctx.appId, ctx.tier);
}
catch (err) {
log?.warn?.({ err }, 'Quota check failed; allowing request');
}
if (quota && !quota.allowed) {
const latencyMs = Date.now() - start;
recordToolRequest(toolName, 'error', false, latencyMs / 1000);
recordToolError(toolName, 'TIER_QUOTA_EXCEEDED');
recordAuthRejection('tier_quota_exceeded');
void audit.recordToolCall({
appId: ctx.appId,
keyId: ctx.keyId,
tool: toolName,
args: redactValue(args).value,
resultBytes: 0,
latencyMs,
cacheHit: false,
...(ctx.ip !== undefined ? { ip: ctx.ip } : {}),
...(ctx.userAgent !== undefined ? { userAgent: ctx.userAgent } : {}),
...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}),
statusCode: 429,
error: 'TIER_QUOTA_EXCEEDED',
});
const err = new ToolError('TIER_QUOTA_EXCEEDED', `Daily request quota exceeded for ${ctx.tier} tier. Try again after ${quota.retryAfter ?? 0}s.`, {
...(quota.retryAfter !== undefined ? { retryAfter: quota.retryAfter } : {}),
suggestedAction: 'Wait for the daily reset or upgrade your tier.',
meta: { tier: ctx.tier, remaining: 0 },
});
return err.toToolResult();
}
// 2. AI quota (только для AI-tools).
if (isAiTool(toolName)) {
let aiQuota = null;
try {
aiQuota = deps.aiQuotaCheck
? await deps.aiQuotaCheck(ctx)
: await usage.checkAiQuota(ctx.appId, ctx.tier);
}
catch (err) {
log?.warn?.({ err }, 'AI quota check failed; allowing request');
}
if (aiQuota && !aiQuota.allowed) {
const latencyMs = Date.now() - start;
recordToolRequest(toolName, 'error', false, latencyMs / 1000);
recordToolError(toolName, 'AI_QUOTA_EXCEEDED');
recordAuthRejection('ai_quota_exceeded');
void audit.recordToolCall({
appId: ctx.appId,
keyId: ctx.keyId,
tool: toolName,
args: redactValue(args).value,
resultBytes: 0,
latencyMs,
cacheHit: false,
...(ctx.ip !== undefined ? { ip: ctx.ip } : {}),
...(ctx.userAgent !== undefined ? { userAgent: ctx.userAgent } : {}),
...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}),
statusCode: 429,
error: 'AI_QUOTA_EXCEEDED',
});
const err = new ToolError('AI_QUOTA_EXCEEDED', `Daily AI quota exceeded for ${ctx.tier} tier. Try again after ${aiQuota.retryAfter ?? 0}s.`, {
...(aiQuota.retryAfter !== undefined ? { retryAfter: aiQuota.retryAfter } : {}),
suggestedAction: 'Wait for the daily reset or upgrade your tier (AI-tools have a separate quota).',
meta: { tier: ctx.tier, remaining: 0, aiQuota: true },
});
return err.toToolResult();
}
}
// 3. Run handler.
let result;
let statusCode = 200;
let errorMessage = null;
try {
result = await handler(args, client);
if (result.isError) {
statusCode = 500;
const meta = (result._meta ?? {});
const code = typeof meta.code === 'string' ? meta.code : 'INTERNAL';
errorMessage = code;
}
}
catch (err) {
statusCode = 500;
errorMessage = err instanceof Error ? err.message : String(err);
const latencyMs = Date.now() - start;
recordToolRequest(toolName, 'error', false, latencyMs / 1000);
recordToolError(toolName, errorMessage);
void audit.recordToolCall({
appId: ctx.appId,
keyId: ctx.keyId,
tool: toolName,
args: redactValue(args).value,
resultBytes: 0,
latencyMs,
cacheHit: false,
...(ctx.ip !== undefined ? { ip: ctx.ip } : {}),
...(ctx.userAgent !== undefined ? { userAgent: ctx.userAgent } : {}),
...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}),
statusCode,
error: errorMessage,
});
throw err;
}
// 3. Redaction
const redacted = applyRedactionToResult(result, deps.redactionConfig);
// Add quota meta if grace zone
if (quota?.overage) {
const meta = (redacted._meta ?? {});
redacted._meta = { ...meta, overage: true, tier: ctx.tier };
}
// 4. Audit + usage + metrics (fire-and-forget).
const latencyMs = Date.now() - start;
const resultBytes = estimateBytes(redacted);
const cacheHit = !!(redacted.structuredContent?._meta &&
redacted.structuredContent._meta.cached === true);
// Metrics (sync).
const status = errorMessage ? 'error' : 'ok';
recordToolRequest(toolName, status, cacheHit, latencyMs / 1000);
if (errorMessage) {
recordToolError(toolName, errorMessage);
}
void audit.recordToolCall({
appId: ctx.appId,
keyId: ctx.keyId,
tool: toolName,
args: redactValue(args).value,
resultBytes,
latencyMs,
cacheHit,
...(ctx.ip !== undefined ? { ip: ctx.ip } : {}),
...(ctx.userAgent !== undefined ? { userAgent: ctx.userAgent } : {}),
...(ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}),
statusCode,
error: errorMessage,
});
void usage.trackUsage({
appId: ctx.appId,
tool: toolName,
bytesOut: resultBytes,
});
return redacted;
};
}
/**
* Применяет redaction к items / log внутри ToolResult.structuredContent.
*
* Эвристика:
* - Если есть `items: array` — прогоняем через `redactErrorLogs`.
* - Если есть `log: object` — прогоняем `redactErrorLog`-аналог.
* - Помечаем `_meta.redactionApplied = true` если что-то отредактировано.
*/
function applyRedactionToResult(result, cfg) {
const sc = result.structuredContent;
if (!sc)
return result;
const newSc = { ...sc };
let redactionAppliedAny = false;
if (Array.isArray(newSc.items)) {
const items = newSc.items;
const { items: redactedItems, stats } = redactErrorLogs(items, cfg ? { config: cfg } : {});
newSc.items = redactedItems;
if (stats.fieldsHit.length > 0 || stats.suspiciousContentBlocked) {
redactionAppliedAny = true;
}
if (stats.suspiciousContentBlocked) {
const meta = (newSc._meta ?? {});
newSc._meta = {
...meta,
suspiciousContentBlocked: true,
suspiciousRule: stats.suspiciousRule,
};
}
}
if (newSc.log && typeof newSc.log === 'object' && !Array.isArray(newSc.log)) {
const { items: redactedItems, stats } = redactErrorLogs([newSc.log], cfg ? { config: cfg } : {});
newSc.log = redactedItems[0];
if (stats.fieldsHit.length > 0 || stats.suspiciousContentBlocked) {
redactionAppliedAny = true;
}
if (stats.suspiciousContentBlocked) {
const meta = (newSc._meta ?? {});
newSc._meta = {
...meta,
suspiciousContentBlocked: true,
suspiciousRule: stats.suspiciousRule,
};
}
}
if (redactionAppliedAny) {
const meta = (newSc._meta ?? {});
newSc._meta = { ...meta, redactionApplied: true };
}
return { ...result, structuredContent: newSc };
}
function estimateBytes(result) {
try {
return Buffer.byteLength(JSON.stringify(result), 'utf8');
}
catch {
return 0;
}
}
//# sourceMappingURL=withMiddleware.js.map