@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-запуска.
139 lines • 5.87 kB
JavaScript
import { z } from 'zod';
import { ToolError } from '../lib/errors.js';
import { encodeCursor, decodeCursor } from '../lib/cursor.js';
import { applyResponseFormat } from '../lib/responseFormat.js';
/**
* Tool: search_logs
*
* Mapping: GET /errors → error-logs-service/src/routes/analytics.routes.ts:33
* Zod schema (upstream): QueryErrorsSchema (page+limit+filters+sort).
*
* Особенности:
* - cursor — transitional wrapper над offset/page (ELS пока offset-based).
* - `level`/`serviceName`/`appVersion` upstream — comma-separated strings,
* поэтому мы плющим массивы tool-input через `.join(',')`.
* - response_format=compact (default) урезает stack/userAgent и message ≤ 200ch.
*/
const LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL', 'CRITICAL'];
const ELS_SORT_BY = ['receivedAt', 'timestamp', 'level', 'message'];
export const searchLogsInputShape = {
from: z.string().optional().describe('ISO timestamp lower bound. Default: 1h ago.'),
to: z.string().optional().describe('ISO timestamp upper bound. Default: now.'),
level: z.array(z.enum(LEVELS)).optional().describe('Filter by error level(s).'),
serviceName: z.array(z.string().max(255)).optional().describe('Filter by service name(s).'),
appVersion: z.array(z.string().max(64)).optional().describe('Filter by app version(s).'),
url: z.string().max(2048).optional(),
fingerprint: z.string().max(128).optional(),
sessionId: z.string().max(128).optional(),
search: z.string().max(500).optional().describe('Full-text search in message + stack.'),
cursor: z.string().nullable().optional().describe('Opaque seek-cursor. Pass null/omit for first page.'),
limit: z.number().int().min(1).max(200).default(20),
sortBy: z.enum(ELS_SORT_BY).default('receivedAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
// eslint-disable-next-line camelcase
response_format: z.enum(['compact', 'full', 'summary']).default('compact'),
};
export const searchLogsToolDef = {
name: 'search_logs',
title: 'Search error logs',
description: [
'Search error logs by facet filters (level, time range, service, version, message).',
'Returns paginated items + facets + histogram.',
'',
'WHEN TO USE:',
' - After finishing a feature locally - verify no new dev errors',
' (deploymentEnv=DEV, from=last 20 minutes).',
' - After deployment - verify no new prod errors (deploymentEnv=PRODUCTION).',
' - User reports unexpected behavior - search by keyword from their report.',
' - Investigating CI failures - pull prod context for same timeframe.',
'',
'DEFAULT: last 1 hour, limit 20, response_format=compact.',
].join('\n'),
inputShape: searchLogsInputShape,
};
/**
* Канонические filters для cursor.filtersHash — должны быть детерминированы
* относительно input (без page/cursor/limit/response_format).
*/
function filtersFromArgs(args) {
return {
from: args.from,
to: args.to,
level: args.level?.slice().sort(),
serviceName: args.serviceName?.slice().sort(),
appVersion: args.appVersion?.slice().sort(),
url: args.url,
fingerprint: args.fingerprint,
sessionId: args.sessionId,
search: args.search,
sortBy: args.sortBy,
sortOrder: args.sortOrder,
};
}
function defaultFrom() {
return new Date(Date.now() - 60 * 60 * 1000).toISOString();
}
export async function handleSearchLogs(args, client) {
try {
const filters = filtersFromArgs(args);
let page = 1;
if (args.cursor && args.cursor.length > 0) {
const decoded = decodeCursor(args.cursor, filters);
page = decoded.page ?? 1;
}
const upstreamParams = {
page,
limit: args.limit,
sortBy: args.sortBy,
sortOrder: args.sortOrder,
from: args.from ?? defaultFrom(),
to: args.to,
search: args.search,
url: args.url,
fingerprint: args.fingerprint,
sessionId: args.sessionId,
levels: args.level?.join(','),
serviceName: args.serviceName?.join(','),
appVersion: args.appVersion?.join(','),
};
const { data, elsRequestId } = await client.searchLogs(upstreamParams);
const raw = data;
const { items: formattedItems, truncated } = applyResponseFormat(raw.items, args.response_format);
const hasMore = raw.page * raw.limit < raw.total;
let nextCursor = null;
if (hasMore && raw.items.length > 0) {
const lastItem = raw.items[raw.items.length - 1];
nextCursor = encodeCursor({ receivedAt: lastItem.receivedAt, id: lastItem.id }, filters, raw.page + 1, raw.limit);
}
const meta = {
elsRequestId,
cached: false,
ttlSec: 15,
redactionApplied: false,
truncated,
};
const structured = {
items: formattedItems,
total: raw.total,
facets: raw.facets,
histogram: raw.histogram,
nextCursor,
_meta: meta,
};
return {
structuredContent: structured,
content: [
{
type: 'text',
text: `Found ${raw.total} matching error logs (showing ${formattedItems.length}, page ${raw.page}/${raw.totalPages}).`,
},
],
};
}
catch (err) {
if (err instanceof ToolError)
return err.toToolResult();
throw err;
}
}
//# sourceMappingURL=searchLogs.js.map