@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-запуска.
228 lines • 12.4 kB
JavaScript
import { z } from 'zod';
/**
* Парсинг и валидация переменных окружения для ELS MCP сервера.
*
* Источники: process.env. Поддерживаются stdio и HTTP transport + OIDC.
*/
const DEFAULT_BASE_URL_DEV = 'http://localhost:4010';
const DEFAULT_BASE_URL_PROD = 'https://api.insoweb.ru/els';
const DEFAULT_OIDC_ISSUER = 'https://auth.insoweb.ru';
const DEFAULT_PUBLIC_URL = 'https://mcp.insoweb.ru/els';
const DEFAULT_CORS_ORIGINS = 'https://claude.ai,https://chat.openai.com';
export const TransportSchema = z.enum(['stdio', 'http']);
export const ConfigSchema = z.object({
/**
* ELS API key для stdio transport / fallback. В HTTP-режиме ключ берётся
* из заголовка `Authorization: Bearer els_...` запроса.
*/
elsApiKey: z.string().default(''),
elsBaseUrl: z.string().url(),
logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
disabledTools: z.array(z.string()).default([]),
/**
* Таймаут upstream-запроса в ELS, мс. Единый таймаут на все ELS-вызовы;
* AI-tools при появлении получат отдельный (более длинный).
*/
upstreamTimeoutMs: z.number().int().positive().default(30_000),
// ─── HTTP transport ────────────────────────────────────────────────────
transport: TransportSchema.default('stdio'),
httpPort: z.number().int().positive().default(3030),
// ─── OIDC ──────────────────────────────────────────────────────────────
oidcIssuer: z.string().url().default(DEFAULT_OIDC_ISSUER),
oidcJwksUrl: z.string().url(),
oidcAudience: z.string().default('els-mcp'),
/**
* Fallback appSlug: используется когда LK resolver
* `GET /api/internal/users/{sub}/apps` недоступен (404/500/timeout) или
* для всех OIDC-юзеров до его реализации.
*/
oidcDemoAppSlug: z.string().optional(),
// ─── discovery / CORS ──────────────────────────────────────────────────
publicUrl: z.string().url().default(DEFAULT_PUBLIC_URL),
corsOrigins: z.array(z.string()).default([]),
// ─── PII-redaction + audit + billing ───────────────────────────────────
/** Включена ли PII-редакция в ответах tools. */
redactionEnabled: z.boolean().default(true),
/** Whitelist полей для редакции (пусто → редактим все известные). */
redactionFields: z.array(z.string()).default([]),
/** Postgres connection для audit/usage. Если пусто — модули в no-op. */
mcpDatabaseUrl: z.string().optional(),
/** AppId по умолчанию (для stdio-mode, когда контекст не задан). */
defaultAppId: z.string().default('default'),
/** Tier по умолчанию (для stdio-mode / прокси-режима). */
defaultTier: z.enum(['FREE', 'STANDARD', 'PREMIUM', 'UNLIMITED']).default('STANDARD'),
// ─── LK API endpoints (для tier/apps resolvers) ────────────────────────
/**
* LK API base URL (для резолва apps по OIDC sub и tier по appSlug).
* Если пустой — используются fallbacks (oidcDemoAppSlug, defaultTier).
* TODO: ожидаемые эндпоинты на LK:
* GET {lkApiBaseUrl}/api/internal/users/{sub}/apps — список appSlugs
* GET {lkApiBaseUrl}/api/internal/apps/{slug}/billing/tier — tier name
*/
lkApiBaseUrl: z.string().optional(),
/** Internal-API service-token для авторизации в LK (если требуется). */
lkApiToken: z.string().optional(),
// ─── Cache & Observability ─────────────────────────────────────────────
/** Redis URL для cache layer. */
redisUrl: z.string().default('redis://localhost:6379'),
/** Включить cache layer. Если false — методы CachedElsClient прозрачно прокидываются в ElsClient. */
cacheEnabled: z.boolean().default(true),
/**
* Per-class TTL overrides. Ключи — имена CacheClass из `cache/policies.ts`.
* Значения — секунды (целые ≥ 0; 0 эффективно отключает кэш для класса).
* Source ENV: `MCP_CACHE_TTL_OVERRIDE_<CLASS>` (напр. `MCP_CACHE_TTL_OVERRIDE_LOG_DETAILS=7200`).
*/
cacheTtlOverrides: z.record(z.string(), z.number().int().nonnegative()).default({}),
/** Включить Prometheus metrics endpoint (`/els/metrics`). */
metricsEnabled: z.boolean().default(true),
/** OTLP endpoint для OpenTelemetry traces. Если не задан — tracing disabled. */
otelExporterOtlpEndpoint: z.string().optional(),
});
function pickBaseUrl(envBase, nodeEnv) {
if (envBase && envBase.trim().length > 0)
return envBase.trim();
// Дефолт production URL — большинство пользователей npm-пакета хочет работать
// с публичным INSO ELS без лишних env vars. Только NODE_ENV=development
// включает localhost (для разработчиков самого MCP-сервера).
if (nodeEnv === 'development')
return DEFAULT_BASE_URL_DEV;
return DEFAULT_BASE_URL_PROD;
}
function parseCsv(csv) {
if (!csv)
return [];
return csv.split(',').map((s) => s.trim()).filter(Boolean);
}
function pickIssuer(envIssuer) {
if (envIssuer && envIssuer.trim().length > 0)
return envIssuer.trim().replace(/\/$/, '');
return DEFAULT_OIDC_ISSUER;
}
function pickJwksUrl(envJwks, issuer) {
if (envJwks && envJwks.trim().length > 0)
return envJwks.trim();
return `${issuer.replace(/\/$/, '')}/oidc/.well-known/jwks.json`;
}
function pickPublicUrl(envPublic) {
if (envPublic && envPublic.trim().length > 0)
return envPublic.trim().replace(/\/$/, '');
return DEFAULT_PUBLIC_URL;
}
/**
* Парсит `MCP_CACHE_TTL_OVERRIDE_<CLASS>` ENV переменные.
* Пример: `MCP_CACHE_TTL_OVERRIDE_LOG_DETAILS=7200` → { log_details: 7200 }.
*/
function parseCacheTtlOverrides(env) {
const prefix = 'MCP_CACHE_TTL_OVERRIDE_';
const result = {};
for (const [k, v] of Object.entries(env)) {
if (!k.startsWith(prefix) || v === undefined)
continue;
const cls = k.slice(prefix.length).toLowerCase();
const n = Number(v);
if (Number.isFinite(n) && n >= 0) {
result[cls] = Math.trunc(n);
}
}
return result;
}
function pickCorsOrigins(env, nodeEnv) {
const base = parseCsv(env ?? DEFAULT_CORS_ORIGINS);
if (nodeEnv !== 'production') {
// localhost для dev (любые порты) — exact match в middleware, regex там же.
base.push('http://localhost', 'http://127.0.0.1');
}
return Array.from(new Set(base));
}
export function loadConfig(env = process.env) {
const issuer = pickIssuer(env.MCP_OIDC_ISSUER);
const jwksUrl = pickJwksUrl(env.MCP_OIDC_JWKS_URL, issuer);
const raw = {
elsApiKey: env.ELS_API_KEY ?? '',
elsBaseUrl: pickBaseUrl(env.ELS_BASE_URL, env.NODE_ENV),
logLevel: (env.MCP_LOG_LEVEL ?? 'info').toLowerCase(),
disabledTools: parseCsv(env.MCP_DISABLE_TOOLS),
upstreamTimeoutMs: env.MCP_UPSTREAM_TIMEOUT_MS
? Number(env.MCP_UPSTREAM_TIMEOUT_MS)
: 30_000,
transport: (env.MCP_TRANSPORT ?? 'stdio').toLowerCase(),
httpPort: env.MCP_HTTP_PORT ? Number(env.MCP_HTTP_PORT) : 3030,
oidcIssuer: issuer,
oidcJwksUrl: jwksUrl,
oidcAudience: env.MCP_OIDC_AUDIENCE ?? 'els-mcp',
oidcDemoAppSlug: env.MCP_OIDC_DEMO_APP_SLUG?.trim() || undefined,
publicUrl: pickPublicUrl(env.MCP_PUBLIC_URL),
corsOrigins: pickCorsOrigins(env.MCP_CORS_ORIGINS, env.NODE_ENV),
redactionEnabled: env.MCP_REDACTION_ENABLED
? env.MCP_REDACTION_ENABLED.toLowerCase() !== 'false'
: true,
redactionFields: parseCsv(env.MCP_REDACTION_FIELDS),
mcpDatabaseUrl: env.MCP_DATABASE_URL?.trim() || undefined,
defaultAppId: env.MCP_DEFAULT_APP_ID?.trim() || 'default',
defaultTier: (env.MCP_DEFAULT_TIER ?? 'STANDARD').toUpperCase(),
lkApiBaseUrl: env.MCP_LK_API_BASE_URL?.trim() || undefined,
lkApiToken: env.MCP_LK_API_TOKEN?.trim() || undefined,
redisUrl: env.MCP_REDIS_URL?.trim() || 'redis://localhost:6379',
cacheEnabled: env.MCP_CACHE_ENABLED ? env.MCP_CACHE_ENABLED.toLowerCase() !== 'false' : true,
cacheTtlOverrides: parseCacheTtlOverrides(env),
metricsEnabled: env.MCP_METRICS_ENABLED
? env.MCP_METRICS_ENABLED.toLowerCase() !== 'false'
: true,
otelExporterOtlpEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() || undefined,
};
const parsed = ConfigSchema.parse(raw);
// stdio-режим без ELS_API_KEY — фатальная ошибка (HTTP-режим допускает
// отсутствие глобального ключа: ключи приходят в запросах).
if (parsed.transport === 'stdio' && parsed.elsApiKey.length === 0) {
throw new Error('ELS_API_KEY is required for stdio transport');
}
// Production-strict ENV validation.
validateProductionEnv(parsed, env);
return parsed;
}
/**
* Production ENV-validation:
* - В `NODE_ENV=production` fail-fast если критичные env vars не заданы.
* - В dev — только warnings в stderr.
*
* Критерии:
* - HTTP transport требует `MCP_OIDC_ISSUER` (явно задан, не default).
* - HTTP transport требует `MCP_PUBLIC_URL` (явно задан).
* - Если cacheEnabled=true → MCP_REDIS_URL должен быть задан (не default localhost).
*/
function validateProductionEnv(cfg, env) {
const isProd = env.NODE_ENV === 'production';
const isTest = env.NODE_ENV === 'test' || env.VITEST === 'true';
const warnings = [];
const errors = [];
if (cfg.transport === 'http') {
if (!env.MCP_OIDC_ISSUER || env.MCP_OIDC_ISSUER.trim().length === 0) {
(isProd ? errors : warnings).push('MCP_OIDC_ISSUER not set (using default — INVALID for production)');
}
if (!env.MCP_PUBLIC_URL || env.MCP_PUBLIC_URL.trim().length === 0) {
(isProd ? errors : warnings).push('MCP_PUBLIC_URL not set (HTTP transport requires explicit public URL in production)');
}
}
// Cache: если cacheEnabled явно (env=true), то MCP_REDIS_URL должен быть задан.
const cacheExplicitlyEnabled = env.MCP_CACHE_ENABLED && env.MCP_CACHE_ENABLED.toLowerCase() === 'true';
if (cacheExplicitlyEnabled) {
if (!env.MCP_REDIS_URL || env.MCP_REDIS_URL.trim().length === 0) {
errors.push('MCP_CACHE_ENABLED=true requires MCP_REDIS_URL (no URL → cannot cache)');
}
}
else if (isProd && cfg.cacheEnabled) {
// В prod cacheEnabled=true (default), но redisUrl=default localhost — warning.
if (!env.MCP_REDIS_URL || env.MCP_REDIS_URL.trim().length === 0) {
warnings.push('MCP_REDIS_URL not set in production (cache will run in degraded mode against localhost)');
}
}
if (warnings.length > 0 && !isTest) {
for (const w of warnings) {
process.stderr.write(`[els-mcp] WARN: ${w}\n`);
}
}
if (errors.length > 0) {
throw new Error(`Production config validation failed:\n - ${errors.join('\n - ')}`);
}
}
//# sourceMappingURL=config.js.map