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

95 lines 4.39 kB
import { createHash } from 'node:crypto'; import { ToolError } from './errors.js'; /** * Seek-cursor utilities. * * Transitional: cursor wraps offset/page (т.к. ELS пока offset-based). * Реальный seek-by-(receivedAt, id) появится после изменения ELS API. * До этого момента: * - `encodeCursor` берёт последний item страницы как anchor, и сохраняет * page/limit для offset-pagination upstream; * - `decodeCursor` разворачивает обратно и проверяет filtersHash, * чтобы клиент не мог тащить cursor между разными filter-наборами. * * Контракт `filtersHash`: SHA-256 канонического JSON (отсортированные ключи) * первых 16 hex-символов. Это компромисс между «защитой» и «коротким cursor'ом». */ const CURSOR_VERSION = 1; /** * Канонический JSON-сериализатор: ключи сортируются, undefined и пустые * массивы выкидываются (чтобы `{ a: undefined }` и `{}` давали одинаковый hash). */ function canonicalJson(value) { if (value === null || value === undefined) return 'null'; if (typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) { const compact = value.filter((v) => v !== undefined); return '[' + compact.map(canonicalJson).join(',') + ']'; } const entries = Object.entries(value) .filter(([, v]) => v !== undefined && !(Array.isArray(v) && v.length === 0)) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalJson(v)).join(',') + '}'; } export function hashFilters(filters) { return createHash('sha256').update(canonicalJson(filters)).digest('hex').slice(0, 16); } /** * Кодирует cursor для следующей страницы. * * @param anchor — последний item текущей страницы (или null если страница пуста). * @param filters — объект текущих фильтров для подсчёта hash. * @param nextPage — следующий номер страницы для offset-pagination. * @param limit — page size. */ export function encodeCursor(anchor, filters, nextPage, limit) { const payload = { anchorReceivedAt: anchor?.receivedAt ?? new Date(0).toISOString(), anchorId: anchor?.id ?? '', filtersHash: hashFilters(filters), v: CURSOR_VERSION, page: nextPage, limit, }; const json = JSON.stringify(payload); return Buffer.from(json, 'utf8').toString('base64url'); } /** * Декодирует cursor и валидирует filters-hash. * * Возвращает payload. Бросает ToolError('INVALID_ARGS') если cursor невалиден * или фильтры между страницами изменились. */ export function decodeCursor(cursor, currentFilters) { let payload; try { const json = Buffer.from(cursor, 'base64url').toString('utf8'); payload = JSON.parse(json); } catch (err) { throw new ToolError('INVALID_ARGS', 'Malformed cursor (not valid base64url JSON)', { cause: err, }); } if (!payload || typeof payload !== 'object') { throw new ToolError('INVALID_ARGS', 'Cursor payload is not an object'); } const p = payload; if (p.v !== CURSOR_VERSION) { throw new ToolError('INVALID_ARGS', `Unsupported cursor version: ${String(p.v)}`); } if (typeof p.anchorReceivedAt !== 'string' || typeof p.anchorId !== 'string') { throw new ToolError('INVALID_ARGS', 'Cursor missing anchor fields'); } if (typeof p.filtersHash !== 'string') { throw new ToolError('INVALID_ARGS', 'Cursor missing filtersHash'); } const expected = hashFilters(currentFilters); if (p.filtersHash !== expected) { throw new ToolError('INVALID_ARGS', 'Filters changed between pages. Restart pagination without cursor.', { suggestedAction: 'Pass cursor=null and re-fetch the first page.' }); } return p; } //# sourceMappingURL=cursor.js.map