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