@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
JavaScript
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