@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
JavaScript
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