UNPKG

@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
/** * 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