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

336 lines 14.3 kB
import { randomUUID } from 'node:crypto'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createMcpServer } from '../server.js'; import { ElsClient } from '../elsClient.js'; import { CachedElsClient, CacheService, resolvePolicies, } from '../cache/index.js'; import { readProjectConfig } from '../discovery/projectConfig.js'; import { buildInstructions } from '../discovery/instructions.js'; import { decSseActive, incSseActive, recordSseRejection, } from '../observability/metrics.js'; import { TIER_LIMITS } from '../billing/limits.js'; /** * Streamable HTTP transport (in-memory sessions). * * Каждый клиент создаёт сессию через первый `initialize`-запрос. После этого * `Mcp-Session-Id` header связывает запросы с конкретной парой * (McpServer + StreamableHTTPServerTransport). * * Sessions хранятся в Map (in-memory). TTL — 30 минут idle. На каждый запрос * сессия "touch'ается" (продлевается). Будет переведено в Redis в следующих * релизах для поддержки горизонтального масштабирования. * * Resumability через `Last-Event-ID` поддерживается SDK 1.x — нам ничего * специально делать не нужно (но `EventStore` пока не подключаем — * `streaming: true` tools'ов нет, все tools — non-streaming). */ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 минут const CLEANUP_INTERVAL_MS = 60 * 1000; // 1 минута export class HttpTransportManager { sessions = new Map(); /** Per-app счётчик active sessions (для concurrency-limit). */ perAppCount = new Map(); ttlMs; cleanupTimer = null; config; log; redis; cachePolicies; middlewareDeps; tierResolver; constructor(opts) { this.config = opts.config; this.log = opts.log; this.ttlMs = opts.sessionTtlMs ?? DEFAULT_TTL_MS; this.redis = opts.redis ?? null; this.cachePolicies = resolvePolicies(opts.config.cacheTtlOverrides); this.middlewareDeps = opts.middlewareDeps ?? {}; this.tierResolver = opts.tierResolver ?? (() => opts.config.defaultTier); if (!opts.noCleanup) { this.cleanupTimer = setInterval(() => this.gcExpired(), CLEANUP_INTERVAL_MS); // Don't block process exit on this timer. this.cleanupTimer.unref?.(); } } /** Closes all sessions and stops cleanup. Безопасно вызывать многократно. */ async close() { if (this.cleanupTimer) clearInterval(this.cleanupTimer); const sessions = Array.from(this.sessions.values()); this.sessions.clear(); this.perAppCount.clear(); for (const rec of sessions) { try { await rec.transport.close(); } catch (err) { this.log.warn({ err: errMsg(err), sessionId: rec.sessionId }, 'transport.close failed'); } try { await rec.server.close(); } catch (err) { this.log.warn({ err: errMsg(err), sessionId: rec.sessionId }, 'server.close failed'); } try { await rec.client.close(); } catch (err) { this.log.warn({ err: errMsg(err), sessionId: rec.sessionId }, 'client.close failed'); } try { decSseActive(rec.appSlug || 'unknown'); } catch { // ignore } } } /** * Express handler для `POST /mcp`. Решает, создавать ли новую сессию * (если нет `Mcp-Session-Id` И тело — initialize) или использовать * существующую. */ async handleRequest(req, res) { const ctx = req.context; if (!ctx) { // Auth middleware должен был отработать раньше — fail-safe. res.status(401).json({ error: 'unauthorized' }); return; } const incomingSid = pickSessionIdHeader(req); const body = req.body; const isInit = isInitializeRequest(body); let rec; if (incomingSid) { rec = this.sessions.get(incomingSid); if (!rec) { // 404 как в SDK semantics: клиенту нужно reinit res.status(404).json({ error: 'session_not_found', sessionId: incomingSid }); return; } this.touch(rec); } else if (isInit) { // Concurrency limit per tier+appSlug. const tier = await Promise.resolve(this.tierResolver(ctx)); const appSlug = ctx.appSlug || 'unknown'; const limit = TIER_LIMITS[tier]?.concurrentSse ?? Number.POSITIVE_INFINITY; const current = this.perAppCount.get(appSlug) ?? 0; if (current >= limit) { recordSseRejection('concurrency_exceeded', appSlug); this.log.warn({ appSlug, tier, current, limit, requestId: ctx.requestId }, 'SSE session-create rejected: concurrency limit exceeded'); res.setHeader('X-RateLimit-Concurrent-Exceeded', 'true'); res.setHeader('X-RateLimit-Concurrent-Limit', String(limit)); res.status(429).json({ error: 'concurrency_limit_exceeded', error_description: `Maximum ${limit} concurrent SSE sessions for tier ${tier}`, tier, limit, }); return; } rec = await this.createSession(ctx); } else { // нет сессии и не initialize → 400 res.status(400).json({ error: 'missing_session_id', error_description: 'Mcp-Session-Id header required for non-initialize requests', }); return; } // Обновим snapshot контекста на каждом запросе (полезно для логов). rec.lastContext = ctx; try { await rec.transport.handleRequest(req, res, body); } catch (err) { this.log.error({ err: errMsg(err), sessionId: rec.sessionId, requestId: ctx.requestId }, 'StreamableHTTP transport.handleRequest threw'); if (!res.headersSent) { res.status(500).json({ error: 'internal', requestId: ctx.requestId }); } } } // ─── private ────────────────────────────────────────────────────────── async createSession(ctx) { const sessionId = randomUUID(); // Каждая сессия получает свой ElsClient: для ELS-key path передаём // ключ запроса, для OIDC — используем глобальный (master) ELS_API_KEY // если задан (TODO: per-tenant credentials через LK). const apiKey = ctx.authMethod === 'els-key' && ctx.elsApiKey ? ctx.elsApiKey : this.config.elsApiKey; // Резолвим tier при создании сессии (LK API + Redis cache); далее // contextProvider использует уже резолвленное значение (TTL кэша // достаточный, чтобы это считать корректным). const tier = await Promise.resolve(this.tierResolver(ctx)); const baseClient = new ElsClient({ baseUrl: this.config.elsBaseUrl, apiKey, timeoutMs: this.config.upstreamTimeoutMs, log: this.log, }); // Если включён cache и Redis доступен — оборачиваем в CachedElsClient. // Сам CachedElsClient gracefully fallback'ит на baseClient при недоступном Redis. let toolClient = baseClient; if (this.config.cacheEnabled && this.redis) { const cacheService = new CacheService({ redis: this.redis, log: this.log }); const cached = new CachedElsClient(baseClient, cacheService, this.cachePolicies, this.log); // CachedElsClient повторяет сигнатуру ElsClient — приводим к этому типу // для совместимости с registerTools / handlers (которые знают только ElsClient). toolClient = cached; } // contextProvider — каждый tool-call получает актуальный ctx // (с appSlug / keyId / tier / ip / userAgent / sessionId). const session = { lastCtx: ctx, sessionId, }; const contextProvider = () => { const cur = session.lastCtx; return { appId: cur.appSlug || this.config.defaultAppId, keyId: cur.keyId, tier, log: this.log, ip: cur.ip || null, userAgent: cur.userAgent || null, sessionId: cur.sessionId ?? session.sessionId, }; }; // Auto-discovery (опционально). В HTTP-режиме при `initialize` мы ещё // не знаем roots клиента (это reverse-request), поэтому используем // server-side ENV-переменную ELS_PROJECT_CONFIG_DIR (per-tenant // deployment) если она задана. Если нет — instructions без project // context, агент работает обобщённо. let projectConfig = null; const envDir = process.env.ELS_PROJECT_CONFIG_DIR?.trim(); if (envDir) { projectConfig = readProjectConfig([envDir], { onWarn: (msg, ctxLog) => this.log.warn(ctxLog ?? {}, `[auto-discovery] ${msg}`), }); } const instructions = buildInstructions({ project: projectConfig }); const { server } = createMcpServer({ config: { ...this.config, elsApiKey: apiKey }, log: this.log, client: toolClient, contextProvider, middleware: this.middlewareDeps, projectConfig, instructions, }); const appSlug = ctx.appSlug || 'unknown'; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessionclosed: (sid) => { this.log.info({ sessionId: sid, appSlug }, 'MCP session closed (DELETE)'); const rec = this.sessions.get(sid); if (rec) { this.sessions.delete(sid); this.decrPerApp(rec.appSlug); try { decSseActive(rec.appSlug || 'unknown'); } catch { // ignore } } }, }); await server.connect(transport); const rec = { sessionId, transport, server, client: baseClient, // close() освобождает undici pool lastContext: ctx, createdAt: Date.now(), lastTouchedAt: Date.now(), appSlug, }; this.sessions.set(sessionId, rec); this.incrPerApp(appSlug); try { incSseActive(appSlug); } catch { // ignore } this.log.info({ sessionId, authMethod: ctx.authMethod, keyId: ctx.keyId, appSlug, cacheEnabled: this.config.cacheEnabled && !!this.redis, }, 'MCP session created'); return rec; } incrPerApp(appSlug) { this.perAppCount.set(appSlug, (this.perAppCount.get(appSlug) ?? 0) + 1); } decrPerApp(appSlug) { const cur = this.perAppCount.get(appSlug) ?? 0; if (cur <= 1) { this.perAppCount.delete(appSlug); } else { this.perAppCount.set(appSlug, cur - 1); } } /** Для тестов: текущее число sessions per app. */ perAppSize(appSlug) { return this.perAppCount.get(appSlug) ?? 0; } touch(rec) { rec.lastTouchedAt = Date.now(); } gcExpired() { const now = Date.now(); let removed = 0; for (const [sid, rec] of this.sessions) { if (now - rec.lastTouchedAt > this.ttlMs) { this.sessions.delete(sid); this.decrPerApp(rec.appSlug); try { decSseActive(rec.appSlug || 'unknown'); } catch { // ignore } rec.transport.close().catch(() => undefined); rec.server.close().catch(() => undefined); rec.client.close().catch(() => undefined); removed += 1; } } if (removed > 0) { this.log.info({ removed, alive: this.sessions.size }, 'MCP session GC ran'); } } /** Для тестов / observability. */ size() { return this.sessions.size; } } function pickSessionIdHeader(req) { const v = req.headers['mcp-session-id']; if (typeof v === 'string' && v.length > 0) return v; return undefined; } function isInitializeRequest(body) { if (!body) return false; if (Array.isArray(body)) { return body.some((m) => isInitializeRequest(m)); } if (typeof body !== 'object') return false; const obj = body; return obj.method === 'initialize'; } function errMsg(err) { return err instanceof Error ? err.message : String(err); } //# sourceMappingURL=http.js.map