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

228 lines 12.4 kB
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