# Разработка `@inso_web/els-mcp`

🇬🇧 **English version**: [`../CONTRIBUTING.md`](../CONTRIBUTING.md)

Документация для тех, кто работает над самим пакетом или поднимает его
локально для отладки. Конечному пользователю достаточно
[`README.ru.md`](./README.ru.md).

## Локальный запуск

```bash
npm install
ELS_API_KEY=els_live_... npm run dev
```

`npm run dev` запускает `tsx src/cli.ts` без предварительной сборки.
После `npm run build` бинарник лежит в `dist/cli.js` — запуск
через `node dist/cli.js`.

stdout зарезервирован под JSON-RPC; все логи идут в stderr.

### Тесты и проверки

```bash
npm test           # vitest run — unit-тесты
npm run typecheck  # tsc --noEmit
```

## ENV-переменные

### Подключение к ELS

| ENV | Default | Описание |
|---|---|---|
| `ELS_API_KEY` | — (обязателен) | Bearer-ключ (`els_live_*` или `els_test_*`) |
| `ELS_BASE_URL` | dev → `http://localhost:4010`, prod → `https://api.insoweb.ru/els` | Upstream ELS endpoint |
| `MCP_LOG_LEVEL` | `info` | pino level |
| `MCP_DISABLE_TOOLS` | — | CSV с именами tools для отключения |
| `MCP_UPSTREAM_TIMEOUT_MS` | `30000` | Таймаут одного ELS-запроса |

### HTTP transport

| ENV | Default | Описание |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` | `stdio` или `http` |
| `MCP_HTTP_PORT` | `3030` | Порт listen для HTTP-режима |
| `MCP_PUBLIC_URL` | `https://mcp.insoweb.ru/els` | URL в WWW-Authenticate и discovery |
| `MCP_OIDC_ISSUER` | `https://auth.insoweb.ru` | OIDC issuer |
| `MCP_OIDC_JWKS_URL` | derived | JWKS endpoint |
| `MCP_OIDC_AUDIENCE` | `els-mcp` | Expected `aud` claim |
| `MCP_OIDC_DEMO_APP_SLUG` | — | Fallback appSlug при недоступности LK resolver |
| `MCP_CORS_ORIGINS` | `https://claude.ai,https://chat.openai.com` | CSV allowed origins (в dev добавляется localhost) |

### Cache, observability, billing

| ENV | Default | Описание |
|---|---|---|
| `MCP_REDIS_URL` | `redis://localhost:6379` | Redis URL |
| `MCP_CACHE_ENABLED` | `true` | Включить cache layer |
| `MCP_METRICS_ENABLED` | `true` | Включить `/els/metrics` |
| `MCP_CACHE_TTL_OVERRIDE_*` | — | Override TTL per class (секунды) |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTLP traces; если не задан → no-op |
| `MCP_LOG_PRETTY` | `true` в dev | Pretty-print pino |
| `MCP_REDACTION_ENABLED` | `true` | Включена ли редакция PII |
| `MCP_REDACTION_FIELDS` | — | CSV whitelist полей (пусто → редактим все) |
| `MCP_DATABASE_URL` | — | Postgres URL для audit/billing. Если пусто → no-op |
| `MCP_DEFAULT_APP_ID` | `default` | Используется в stdio-режиме |
| `MCP_DEFAULT_TIER` | `STANDARD` | Tier по умолчанию для quota-check |
| `MCP_LK_API_BASE_URL` | — | LK API URL для OIDC sub→apps и appSlug→tier (опц.) |
| `MCP_LK_API_TOKEN` | — | Bearer-токен для internal LK API |

## HTTP transport локально

```bash
MCP_TRANSPORT=http \
MCP_HTTP_PORT=3030 \
MCP_OIDC_DEMO_APP_SLUG=acme \
ELS_API_KEY=els_live_xxx \
npm run dev
```

OIDC можно направить на dev-инстанс INSO Auth:

```bash
MCP_OIDC_ISSUER=http://localhost:4002 \
MCP_OIDC_JWKS_URL=http://localhost:4002/oidc/.well-known/jwks.json \
MCP_TRANSPORT=http npm run dev
```

### Маршруты

| Method | URL | Назначение |
|---|---|---|
| `POST /els/mcp` | MCP JSON-RPC (Streamable HTTP) | Требует Bearer (ELS-key или OIDC JWT) |
| `GET /els/mcp` | Long-lived SSE (server → client notifications) | Требует Bearer |
| `DELETE /els/mcp` | Terminate session | Требует Bearer |
| `GET /els/healthz` | Liveness probe (всегда 200) | Public |
| `GET /els/readyz` | Readiness probe (ELS upstream check) | Public |
| `GET /els/.well-known/oauth-protected-resource` | RFC 9728 resource metadata | Public |
| `GET /els/.well-known/mcp` | MCP discovery (tools list, transports) | Public |
| `GET /els/metrics` | Prometheus text format | Public |

### Быстрые curl-проверки

```bash
# Liveness
curl http://localhost:3030/els/healthz
# {"status":"ok"}

# Resource metadata
curl http://localhost:3030/els/.well-known/oauth-protected-resource

# MCP discovery
curl http://localhost:3030/els/.well-known/mcp

# Bearer ELS-key
curl -X POST http://localhost:3030/els/mcp \
  -H "Authorization: Bearer els_live_xxx" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"curl","version":"1"},"capabilities":{}}}'
# В ответе будет Mcp-Session-Id header — используйте его для последующих запросов.
```

## Авторизация

Поддерживаются два сценария (детектится по shape Bearer):

1. **ELS-ключ** — `Authorization: Bearer els_(live|test)_<key>` — passthrough в ELS.
   Используется в CI/CD, server-to-server и debug-сценариях.
2. **OIDC JWT** — `Authorization: Bearer <jwt>` — локально валидируется через
   JWKS INSO Auth (`https://auth.insoweb.ru/oidc/.well-known/jwks.json`,
   RS256, audience `els-mcp`, scope `errors:mcp-read`).

Если оба Bearer-а отсутствуют — 401 + `WWW-Authenticate: Bearer
realm="els-mcp",
resource_metadata="https://mcp.insoweb.ru/els/.well-known/oauth-protected-resource"`.

### Sessions

`Mcp-Session-Id` header возвращается на первый `initialize`-запрос и должен
передаваться во все последующие. TTL — 30 мин idle. Хранение in-memory
(Map), будет переведено в Redis в следующих релизах.

### OIDC sub → appSlug resolver

При наличии LK API эндпоинта `GET /api/internal/users/{sub}/apps` сервис
резолвит доступные пользователю apps и кэширует результат в Redis 5 минут.
Если эндпоинт недоступен — graceful fallback на `MCP_OIDC_DEMO_APP_SLUG`.
Если у пользователя несколько apps, tool принимает optional `appSlug`
параметр; иначе берётся первый.

## Prompt-injection mitigation

Все строковые поля из логов оборачиваются в `<untrusted>…</untrusted>`
теги. В description каждого tool — system note, что LLM **не** должен
следовать инструкциям из такого контента. Параллельно работает regex
deny-list (см. `src/redaction/promptInjection.ts`): при совпадении
(`ignore previous instructions`, `system:`, `jailbreak`, …) в
`_meta.suspiciousContentBlocked = true` + `_meta.suspiciousRule = <name>`.

## Audit log

- Append-only, schema `mcp_audit` (отдельная БД от ELS).
- Hash-chain: `prevHash` + `rowHash` (sha256) per `appId`-partition.
- Партиционирование по месяцу (RANGE `createdAt`). См.
  `prisma/migrations/init/migration.sql`.
- Запись non-blocking: если БД недоступна, tool-call продолжает работать
  (silent fail с warn-логом).

### Что НЕ пишется в audit

- Полный API-ключ (только prefix 8 символов).
- Контент логов (только метаданные tool-call).
- Полный IP (anonymized).
- Cookies, Authorization headers.

### Проверка целостности hash-chain

```bash
# Integrity check audit log для app 'acme'
MCP_DATABASE_URL=postgres://... npm run audit:verify -- --app=acme

# С диапазоном дат
els-mcp verify-audit --app=acme --from=2026-05-01 --to=2026-05-17
```

Exit code `0` — цепочка целая; `1` — найден разрыв (с указанием
проблемной строки).

## Prisma setup

```bash
# Сгенерировать клиент (output → node_modules/.prisma/mcp)
npm run prisma:generate

# Применить миграцию (создаёт schemas + partitioned audit table)
psql $MCP_DATABASE_URL -f prisma/migrations/init/migration.sql
```

## Cache (Redis)

Lookup-aside кэш для read-heavy эндпоинтов. TTL — по классам (см.
`src/cache/policies.ts`):

| Class | TTL | Tool(s) |
|---|---|---|
| `log_details` | 1h | `get_log_details` |
| `top_messages` | 2m | `top_error_messages` |
| `histogram` | 1m | `error_histogram` |
| `heatmap` | 5m | `error_heatmap` |
| `traffic_long` | 5m | `traffic_stats` |
| `search_recent` | 15s | `search_logs` |
| `list_apps` | 30s | `list_apps` |
| `stats_breakdown` | 2m | `error_stats_breakdown` |
| `baseline` | 5m | `baseline_compare` |
| `version_timeline` | 5m | `version_regression` |
| `grouped_errors` | 2m | `grouped_errors` |

Все cache-keys обязательно tenant-prefixed:
`mcp:cache:{class}:{appSlug | k:keyPrefix}:{...}` — защита от
cross-tenant data leak.

**Graceful degradation**. Если Redis недоступен или
`MCP_CACHE_ENABLED=false` — все запросы прозрачно идут в ELS без ошибок.
Sub-25ms задержка коннекта/PING не блокирует старт процесса
(`lazyConnect: true`).

**Compression**. Значения > 10 KB автоматически gzip-сжимаются (префикс
`gz:`) и расшифровываются при чтении.

## Prometheus metrics

Endpoint: `GET /els/metrics`.

Ключевые метрики:

- `mcp_requests_total{tool,status,cached}`
- `mcp_request_duration_seconds{tool}` — histogram (buckets:
  0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30)
- `mcp_errors_total{tool,code}`
- `mcp_cache_hits_total{tool_class}`, `mcp_cache_misses_total{tool_class}`,
  `mcp_cache_hit_ratio{tool_class}`
- `mcp_els_upstream_errors_total{endpoint,status}`
- `mcp_sse_connections_active`
- `mcp_redaction_applied_total{field}`
- `mcp_billing_events_total{appSlug,tier}`

### Prometheus scrape config

```yaml
scrape_configs:
  - job_name: els-mcp
    scrape_interval: 15s
    metrics_path: /els/metrics
    static_configs:
      - targets: ['mcp-1.internal:3030', 'mcp-2.internal:3030']
```

### Grafana dashboard (минимальный JSON)

```json
{
  "title": "MCP — Overview",
  "panels": [
    { "title": "RPS by tool",
      "targets": [{ "expr": "sum by (tool) (rate(mcp_requests_total[1m]))" }] },
    { "title": "p95 latency",
      "targets": [{ "expr": "histogram_quantile(0.95, sum by (tool, le) (rate(mcp_request_duration_seconds_bucket[5m])))" }] },
    { "title": "Cache hit ratio",
      "targets": [{ "expr": "mcp_cache_hit_ratio" }] },
    { "title": "Upstream errors",
      "targets": [{ "expr": "sum by (status) (rate(mcp_els_upstream_errors_total[5m]))" }] }
  ]
}
```

Полный SRE-dashboard + per-tool + per-tenant — `07-observability.md`.

## Логи (Loki shipper)

Логи pino → stderr (stdio-mode) или stdout (HTTP-mode) → Promtail → Loki.

```yaml
scrape_configs:
  - job_name: els-mcp
    static_configs:
      - targets: [localhost]
        labels:
          job: els-mcp
          service: els-mcp
          __path__: /var/log/els-mcp/*.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            tool: tool
            appSlug: appSlug
            requestId: requestId
      - labels:
          level:
          tool:
```

Чувствительные поля (`*.token`, `*.apiKey`, `Authorization` headers и
т. д.) автоматически замещаются на `<REDACTED>` в pino-логах
(см. `src/observability/logger.ts`).

## OpenTelemetry tracing

Опциональный — включается через `OTEL_EXPORTER_OTLP_ENDPOINT`. Если не
задан, SDK вообще не загружается (zero overhead).

Auto-instrumentation: HTTP, undici (ELS calls), ioredis, Express.

## Health endpoints

- `GET /els/healthz` — liveness (всегда 200 если процесс жив).
- `GET /els/readyz` — readiness: проверяет Redis ping + ELS upstream
  reachability. Возвращает 503 если хотя бы одна зависимость не отвечает.
  Готовые handler'ы — `src/http/routes/metrics.ts`.

## Публикация в npm

Релиз автоматизирован через GitLab CI:

1. Bump version в `package.json` (`0.3.x` → `0.3.(x+1)` для bug-fix,
   `0.(x+1).0` для новых фич).
2. Commit изменений в `master`.
3. Создать tag формата `sdk/mcp/v<X.Y.Z>`:
   ```bash
   git tag sdk/mcp/v0.3.1
   git push origin master
   git push origin sdk/mcp/v0.3.1
   ```
4. GitLab job `publish:mcp` (см. `.gitlab-ci.yml`) сработает по tag,
   выполнит `npm version`, `npm run build`, `npm publish --access public`.

Требуется `NPM_TOKEN` в protected CI/CD-переменных GitLab.

## Limitations / TODO

- **DCR (Dynamic Client Registration).** Rate-limit middleware готов
  (`src/http/middleware/dcrRateLimit.ts`), но `/oauth/register` endpoint
  планируется в v2. Сейчас используется OIDC discovery без runtime
  регистрации клиентов.
- **Mistral AI summary в `explain_error`.** Сейчас tool возвращает
  контекст ошибки без AI-обёртки (`aiAvailable=false`); LLM-клиент сам
  синтезирует объяснение из переданных данных. Планируется в следующих
  релизах.
- **OIDC sub → apps resolver через LK API.** Эндпоинт
  `GET /api/internal/users/{sub}/apps` ожидается на LK backend; до его
  появления используется fallback на `MCP_OIDC_DEMO_APP_SLUG`.
- **Tier resolver через LK API.** Эндпоинт
  `GET /api/internal/apps/{appSlug}/billing/tier` ожидается на LK backend;
  до его появления используется `MCP_DEFAULT_TIER`.
