@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-запуска.
194 lines • 7.48 kB
JavaScript
/**
* LK API resolvers.
*
* Два резолвера дёргают LK backend и кэшируются в Redis (если доступен):
*
* 1. `resolveOidcSubApps(sub)` — `GET /api/internal/users/{sub}/apps`
* возвращает список доступных пользователю appSlugs.
* Кэш: `mcp:oidc:apps:{sub}` TTL 5 min.
*
* 2. `resolveAppTier(appSlug)` — `GET /api/internal/apps/{slug}/billing/tier`
* возвращает один из `FREE`/`STANDARD`/`PREMIUM`/`UNLIMITED`.
* Кэш: `mcp:tier:{appSlug}` TTL 30 sec.
*
* TODO (LK backend): оба эндпоинта **пока не реализованы** — резолверы
* корректно отрабатывают 404/500/timeout, возвращая fallback значения.
* Когда эндпоинты появятся, никакого изменения кода не потребуется.
*
* Все вызовы — best-effort: при недоступности LK сервис **не** падает,
* а использует safety-net fallback (`MCP_OIDC_DEMO_APP_SLUG`,
* `config.defaultTier`).
*/
const TIERS = new Set(['FREE', 'STANDARD', 'PREMIUM', 'UNLIMITED']);
const APPS_CACHE_TTL_SEC = 300; // 5 min
const TIER_CACHE_TTL_SEC = 30;
const LK_REQUEST_TIMEOUT_MS = 2_000;
export function createLkResolver(opts) {
const fetchImpl = opts.fetchImpl ?? fetch;
const log = opts.log;
const baseUrl = opts.lkApiBaseUrl?.replace(/\/$/, '') ?? null;
async function getCached(key) {
if (!opts.redis)
return null;
try {
return await opts.redis.get(key);
}
catch (err) {
log?.debug?.({ err, key }, 'lkResolver: redis get failed (degraded)');
return null;
}
}
async function setCached(key, value, ttlSec) {
if (!opts.redis)
return;
try {
await opts.redis.set(key, value, 'EX', ttlSec);
}
catch (err) {
log?.debug?.({ err, key }, 'lkResolver: redis set failed (degraded)');
}
}
function authHeaders() {
const headers = { Accept: 'application/json' };
if (opts.lkApiToken)
headers.Authorization = `Bearer ${opts.lkApiToken}`;
return headers;
}
async function doFetch(url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), LK_REQUEST_TIMEOUT_MS);
try {
const res = await fetchImpl(url, { headers: authHeaders(), signal: ctrl.signal });
return res;
}
catch (err) {
log?.debug?.({ err, url }, 'lkResolver: LK fetch failed (degraded)');
return null;
}
finally {
clearTimeout(timer);
}
}
return {
async resolveOidcSubApps(sub) {
const cacheKey = `mcp:oidc:apps:${sub}`;
const cached = await getCached(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed.apps) && parsed.apps.length > 0) {
return { apps: parsed.apps, fromLk: parsed.fromLk === true };
}
}
catch {
// Ignore — fall through to LK fetch.
}
}
const fallback = {
apps: opts.fallbackAppSlug ? [opts.fallbackAppSlug] : [],
fromLk: false,
};
if (!baseUrl)
return fallback;
const url = `${baseUrl}/api/internal/users/${encodeURIComponent(sub)}/apps`;
const res = await doFetch(url);
if (!res)
return fallback;
if (res.status === 404 || res.status >= 500) {
log?.debug?.({ sub, status: res.status, url }, 'lkResolver: LK returned error status; using fallback');
return fallback;
}
if (res.status !== 200) {
log?.debug?.({ sub, status: res.status }, 'lkResolver: unexpected LK status; using fallback');
return fallback;
}
let body = null;
try {
body = await res.json();
}
catch (err) {
log?.debug?.({ err, sub }, 'lkResolver: failed to parse LK response');
return fallback;
}
const apps = extractApps(body);
if (apps.length === 0) {
log?.debug?.({ sub }, 'lkResolver: LK returned empty app list; using fallback');
return fallback;
}
const result = { apps, fromLk: true };
await setCached(cacheKey, JSON.stringify(result), APPS_CACHE_TTL_SEC);
return result;
},
async resolveAppTier(appSlug) {
const cacheKey = `mcp:tier:${appSlug}`;
const cached = await getCached(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (typeof parsed.tier === 'string' && TIERS.has(parsed.tier)) {
return { tier: parsed.tier, fromLk: parsed.fromLk === true };
}
}
catch {
// Ignore — fall through to LK fetch.
}
}
const fallback = { tier: opts.fallbackTier, fromLk: false };
if (!baseUrl)
return fallback;
const url = `${baseUrl}/api/internal/apps/${encodeURIComponent(appSlug)}/billing/tier`;
const res = await doFetch(url);
if (!res)
return fallback;
if (res.status === 404 || res.status >= 500) {
log?.debug?.({ appSlug, status: res.status, url }, 'lkResolver: LK tier endpoint returned error; using fallback');
return fallback;
}
if (res.status !== 200)
return fallback;
let body = null;
try {
body = await res.json();
}
catch {
return fallback;
}
const tier = extractTier(body);
if (!tier)
return fallback;
const result = { tier, fromLk: true };
await setCached(cacheKey, JSON.stringify(result), TIER_CACHE_TTL_SEC);
return result;
},
};
}
function extractApps(body) {
if (!body || typeof body !== 'object')
return [];
const b = body;
const raw = b.apps ?? b.appSlugs ?? b.data;
if (!Array.isArray(raw))
return [];
const apps = [];
for (const item of raw) {
if (typeof item === 'string' && item.length > 0)
apps.push(item);
else if (item && typeof item === 'object') {
const slug = item.slug ?? item.appSlug;
if (typeof slug === 'string' && slug.length > 0)
apps.push(slug);
}
}
return Array.from(new Set(apps));
}
function extractTier(body) {
if (!body || typeof body !== 'object')
return null;
const b = body;
const raw = b.tier ?? b.name ?? b.plan;
if (typeof raw !== 'string')
return null;
const upper = raw.toUpperCase();
return TIERS.has(upper) ? upper : null;
}
//# sourceMappingURL=lkResolver.js.map