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-запуска.

285 lines 11.9 kB
import { Pool } from 'undici'; import { mapHttpToToolError, ToolError } from './lib/errors.js'; /** * Аналитические эндпоинты, которые покрывает клиент: * - POST /errors/query → queryLogsJql * - GET /analytics/stats → errorStats * - GET /analytics/grouped-errors → groupedErrors * - GET /analytics/impact → impact * - GET /analytics/baseline → baseline * - GET /analytics/version-timeline→ versionRegression * - GET /analytics/heatmap → heatmap * * POST-варианты с filter-AST (`/analytics/<x>/query`) сейчас не выставляются * как отдельные tools — LLM может сформировать AST через `query_logs_jql`. */ /** * Тонкий read-only клиент над ELS HTTP API. * * Дизайн: * - undici `Pool` с keep-alive (per-process, 16 connections). * - Bearer auth (ELS API key). * - Retry 5xx: 3 попытки, exponential backoff (250ms / 500ms / 1000ms). * - 429 НЕ ретраится — прокидывается LLM'у через ToolError + retryAfter. * - Timeout 30s per request (single ELS hop; AI-tools получат свой, * более длинный таймаут когда будут добавлены). * * Маппинг tools → endpoints (см. README + 03-tools-catalog.md): * - GET /errors → searchLogs * - GET /errors/:traceId → getLogDetails * - GET /errors/:traceId/similar → findSimilarErrors * - GET /errors/:traceId/correlated → findCorrelatedErrors * - GET /analytics/top-messages → topErrorMessages * - GET /analytics/histogram → errorHistogram * - GET /analytics/traffic → trafficStats * - GET /apps → listApps (master-only; на 403 → graceful fallback в tool) */ const MAX_RETRIES = 3; const BASE_BACKOFF_MS = 250; export class ElsClient { baseUrl; origin; basePath; apiKey; timeoutMs; log; dispatcher; ownDispatcher; constructor(opts) { this.baseUrl = opts.baseUrl.replace(/\/$/, ''); const u = new URL(this.baseUrl); this.origin = `${u.protocol}//${u.host}`; this.basePath = u.pathname.replace(/\/$/, ''); this.apiKey = opts.apiKey; this.timeoutMs = opts.timeoutMs ?? 30_000; this.log = opts.log; if (opts.dispatcher) { this.dispatcher = opts.dispatcher; this.ownDispatcher = false; } else { this.dispatcher = new Pool(this.origin, { connections: 16, keepAliveTimeout: 60_000, keepAliveMaxTimeout: 300_000, }); this.ownDispatcher = true; } } async close() { if (this.ownDispatcher && 'close' in this.dispatcher) { await this.dispatcher.close(); } } /** Низкоуровневый GET с retry/backoff. Возвращает уже-распарсенный JSON. */ async request(path, opts = {}) { const qs = new URLSearchParams(); if (opts.query) { for (const [k, v] of Object.entries(opts.query)) { if (v === undefined || v === null || v === '') continue; qs.append(k, String(v)); } } const fullPath = `${this.basePath}${path}` + (qs.toString() ? `?${qs.toString()}` : ''); let attempt = 0; let lastError; while (attempt <= (opts.noRetry ? 0 : MAX_RETRIES)) { attempt += 1; try { const method = opts.method ?? 'GET'; const hasBody = method === 'POST' && opts.body !== undefined; const res = await this.dispatcher.request({ origin: this.origin, method, path: fullPath, headers: { authorization: `Bearer ${this.apiKey}`, accept: 'application/json', 'user-agent': 'els-mcp/0.1', ...(hasBody ? { 'content-type': 'application/json' } : {}), }, body: hasBody ? JSON.stringify(opts.body) : undefined, headersTimeout: this.timeoutMs, bodyTimeout: this.timeoutMs, }); const status = res.statusCode; const text = await res.body.text(); let body; try { body = text ? JSON.parse(text) : null; } catch { body = text; } const headers = {}; for (const [k, v] of Object.entries(res.headers)) { headers[k.toLowerCase()] = v; } if (status === 429) { const retryAfter = pickHeader(headers, 'retry-after'); throw mapHttpToToolError(429, body, retryAfter ?? null); } if (status >= 500 && attempt <= MAX_RETRIES && !opts.noRetry) { this.log?.warn?.({ path: fullPath, status, attempt }, 'ELS upstream 5xx, will retry'); await sleep(BASE_BACKOFF_MS * Math.pow(2, attempt - 1)); continue; } if (status >= 400) { throw mapHttpToToolError(status, body, pickHeader(headers, 'retry-after') ?? null); } return { status, body, headers }; } catch (err) { if (err instanceof ToolError) throw err; lastError = err; if (attempt > MAX_RETRIES || opts.noRetry) { this.log?.error?.({ err, path: fullPath, attempt }, 'ELS request failed'); throw new ToolError('UPSTREAM_UNAVAILABLE', `ELS request failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err, retryAfter: 5 }); } await sleep(BASE_BACKOFF_MS * Math.pow(2, attempt - 1)); } } throw new ToolError('UPSTREAM_UNAVAILABLE', `ELS request exhausted retries: ${lastError instanceof Error ? lastError.message : String(lastError)}`, { cause: lastError, retryAfter: 5 }); } // ─── Public methods (per-tool) ─────────────────────────────────────────── async searchLogs(params) { const res = await this.request('/errors', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } async getLogDetails(traceId) { const encoded = encodeURIComponent(traceId); const res = await this.request(`/errors/${encoded}`); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } async findSimilarErrors(traceId, params = {}) { const encoded = encodeURIComponent(traceId); const res = await this.request(`/errors/${encoded}/similar`, { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } async findCorrelatedErrors(traceId, params = {}) { const encoded = encodeURIComponent(traceId); const res = await this.request(`/errors/${encoded}/correlated`, { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } async topErrorMessages(params) { const res = await this.request('/analytics/top-messages', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } async errorHistogram(params) { const res = await this.request('/analytics/histogram', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } async trafficStats(params) { const res = await this.request('/analytics/traffic', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** * GET /apps — требует master-key (`requireMaster`). Для обычного ELS-key * вернёт 403. Tool listApps в свою очередь обрабатывает 403 и возвращает * derived-from-context результат. */ async listApps() { const res = await this.request('/apps'); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } // ─── Analytics endpoints ────────────────────────────────────────────────── /** * POST /errors/query — JQL-style structured filter (AST + pagination + search). * Возвращает items + total + facets + histogram (та же форма что GET /errors). */ async queryLogsJql(body) { const res = await this.request('/errors/query', { method: 'POST', body }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** GET /analytics/stats. */ async errorStats(params) { const res = await this.request('/analytics/stats', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** GET /analytics/grouped-errors. */ async groupedErrors(params) { const res = await this.request('/analytics/grouped-errors', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** GET /analytics/impact. */ async impact(params) { const res = await this.request('/analytics/impact', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** GET /analytics/baseline. */ async baseline(params) { const res = await this.request('/analytics/baseline', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** GET /analytics/version-timeline. */ async versionRegression(params) { const res = await this.request('/analytics/version-timeline', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } /** GET /analytics/heatmap — 7×24 матрица счётчиков. */ async heatmap(params) { const res = await this.request('/analytics/heatmap', { query: params }); return { data: res.body, elsRequestId: pickStringHeader(res.headers, 'x-request-id'), }; } } function pickHeader(headers, name) { const v = headers[name.toLowerCase()]; if (Array.isArray(v)) return v[0]; return v; } function pickStringHeader(headers, name) { const v = pickHeader(headers, name); return typeof v === 'string' ? v : null; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } //# sourceMappingURL=elsClient.js.map