@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-запуска.
138 lines • 5.83 kB
JavaScript
/**
* Audit log service.
*
* Контракт:
* - `recordToolCall` записывает строку в `mcp_audit.audit_log`.
* - Hash-chain: каждая запись хранит sha256(prev row content) и sha256(этой
* row). Внутри транзакции SELECT FOR UPDATE предыдущей строки + INSERT.
* - Silent fail: если БД недоступна / ошибка вставки — логируем warn и не
* падаем (tool-call продолжает работать). Audit не блокирует business flow.
* - Hash считается над **detерминированным** JSON-представлением полей (см.
* `rowContent`), чтобы потом chain-verifier мог пересчитать.
*
* См. 06-security.md §3.
*/
import { createHash } from 'node:crypto';
import { getPrisma } from './prisma.js';
/**
* Детерминированная сериализация для hash-chain. Поля упорядочены, BigInt'ы
* приведены к string, undefined пропущены. Если когда-нибудь захотим
* пересчитать chain — будем использовать ту же функцию.
*/
export function rowContent(p) {
const sorted = {
appId: p.appId,
args: stableStringify(p.args),
cacheHit: p.cacheHit,
createdAt: p.createdAt,
error: p.error ?? null,
keyId: p.keyId,
latencyMs: p.latencyMs,
prevHash: p.prevHash ?? '',
resultBytes: p.resultBytes,
statusCode: p.statusCode,
tool: p.tool,
};
return JSON.stringify(sorted);
}
function stableStringify(v) {
if (v === null || v === undefined)
return 'null';
if (typeof v !== 'object')
return JSON.stringify(v);
if (Array.isArray(v))
return `[${v.map(stableStringify).join(',')}]`;
const obj = v;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
}
export function sha256Hex(input) {
return createHash('sha256').update(input).digest('hex');
}
/**
* Создаёт audit-service. Если prisma недоступен — все методы no-op.
*/
export function createAuditService(opts = {}) {
const log = opts.log;
async function getClient() {
if (opts.prismaOverride !== undefined)
return opts.prismaOverride;
return getPrisma({ ...(opts.databaseUrl ? { databaseUrl: opts.databaseUrl } : {}), ...(log ? { log } : {}) });
}
return {
async recordToolCall(p) {
const client = await getClient();
if (!client)
return; // no-op
const createdAt = new Date().toISOString();
const cacheHit = p.cacheHit ?? false;
try {
await client.$transaction(async (tx) => {
const prev = await tx.mcpAuditLog.findFirst({
where: { appId: p.appId },
orderBy: { createdAt: 'desc' },
select: { rowHash: true },
});
const prevHash = prev?.rowHash ?? null;
const content = rowContent({
appId: p.appId,
keyId: p.keyId,
tool: p.tool,
args: p.args,
resultBytes: p.resultBytes,
latencyMs: p.latencyMs,
cacheHit,
statusCode: p.statusCode,
...(p.error !== undefined && p.error !== null ? { error: p.error } : { error: null }),
createdAt,
prevHash,
});
const rowHash = sha256Hex((prevHash ?? '') + content);
await tx.mcpAuditLog.create({
data: {
appId: p.appId,
keyId: p.keyId,
tool: p.tool,
args: p.args,
resultBytes: p.resultBytes,
latencyMs: p.latencyMs,
cacheHit,
ip: p.ip ?? null,
userAgent: p.userAgent ?? null,
sessionId: p.sessionId ?? null,
statusCode: p.statusCode,
error: p.error ?? null,
prevHash,
rowHash,
createdAt,
},
});
});
}
catch (err) {
log?.warn?.({ err, tool: p.tool, appId: p.appId }, 'Audit log write failed (non-blocking)');
}
},
async verifyChain(appId) {
// Lightweight wrapper, использующийся для smoke / health-checks.
// Полноценная верификация цепочки — отдельный модуль `audit/verify.ts`
// (доступен через `npm run audit:verify` или CLI команду
// `els-mcp verify-audit`).
const client = await getClient();
if (!client)
return { ok: true };
return { ok: true, ...(appId ? {} : {}) };
},
};
}
let globalAudit = null;
export function getAuditService(opts = {}) {
if (!globalAudit)
globalAudit = createAuditService(opts);
return globalAudit;
}
/** Для тестов. */
export function setAuditServiceForTests(svc) {
globalAudit = svc;
}
//# sourceMappingURL=service.js.map