docs: add dbmole-mcp design spec

This commit is contained in:
smartass
2026-06-11 22:07:34 +05:00
commit 47218d09b8
@@ -0,0 +1,173 @@
# dbmole-mcp — дизайн
MCP-сервер (stdio) для работы с PostgreSQL и MySQL через именованные подключения.
Решаемая боль: фиксированные env-конфиги требуют перезапуска харнеса при каждом
изменении настроек. dbmole хранит подключения персистентно и позволяет управлять
ими тулзами в рантайме, без перезапуска. Переносимость — npm-пакет и Docker-образ.
## Стек
- TypeScript, ESM, Node >= 20
- `@modelcontextprotocol/sdk` — McpServer + StdioServerTransport
- `zod` — схемы входов тулзов и валидация конфигов
- `pg` — драйвер PostgreSQL, `mysql2` — драйвер MySQL
- `ssh2` — SSH-туннели (pure JS, не требует ssh-бинаря в Docker)
- `tsup` — сборка bin с shebang
- Biome — линт и формат: indent 4 пробела, `semicolons: asNeeded`,
`quoteStyle: single`; функции — `const fn = () => {}` где возможно
- `vitest` + `@vitest/coverage-v8` — тесты, `testcontainers` — интеграционные
## Каскад источников подключений
Выше — приоритетнее при совпадении имён:
1. env `DBMOLE_CONNECTIONS` — JSON-массив конфигов, readonly в рантайме
2. конфиг-файл: `--config <path>` или env `DBMOLE_CONFIG` (путь),
формат `{ "connections": [...] }`, readonly в рантайме
3. персистентный стор `~/.config/dbmole/connections.json`
(права 0600, путь переопределяется env `DBMOLE_STORE`) —
мутируется тулзами add/update/remove_connection
Правила:
- Стор перечитывается при каждом обращении (resolve, list, mutate) — без
кеширования файла. Несколько одновременных инстансов dbmole видят изменения
друг друга сразу.
- Запись стора атомарная: tmp-файл + rename. Перед каждой мутацией — re-read
(read-modify-write), последняя запись побеждает.
- У каждого подключения в выдаче `list_connections` есть `source: env | config | store`.
- `update/remove_connection` по записи из env/config → ошибка с указанием источника.
- `add_connection` с именем, занятым любым слоем, → ошибка (никаких теневых записей).
## Схема подключения
```ts
{
name: string // [a-zA-Z0-9_-]+, уникально в рамках слоя
type: 'postgres' | 'mysql'
host: string
port?: number // default: 5432 (pg) / 3306 (mysql)
user: string
password?: string
database?: string // дефолтная БД для тулзов без явного database
readonly?: boolean // default: false
ssh?: {
host: string
port?: number // default: 22
user: string
password?: string
privateKey?: string // inline PEM
privateKeyPath?: string // путь, '~' разворачивается
passphrase?: string
agent?: boolean // использовать SSH_AUTH_SOCK
}
}
```
Валидация zod на всех входах: и тулзовых, и из env/файлов. Ошибка в одном
конфиге из источника не валит сервер — битая запись скипается с warning в stderr.
## Runtime-архитектура
```
src/
index.ts — entry: argv/env, wire-up, stdio transport, shutdown
server.ts — McpServer, регистрация тулзов
config/
types.ts — zod-схемы ConnectionConfig, SshConfig
sources.ts — чтение env-слоя и config-файла
store.ts — персистентный стор: атомарные read/write, 0600
registry.ts — мердж каскада, CRUD store-слоя, resolve(name) → {config, source, hash}
net/
tunnel.ts — SshTunnel: ssh2-клиент + локальный TCP-форвард
db/
driver.ts — интерфейс Driver
postgres.ts — pg-реализация
mysql.ts — mysql2-реализация
manager.ts — ConnectionManager: кеш name → {tunnel?, driver, hash}
tools/
connections.ts — list/add/update/remove/test_connection
query.ts — execute_sql
schema.ts — list_databases, list_tables, describe_table
format.ts — приведение результатов, rowLimit, truncation
```
- **registry** — единственная точка чтения конфигов. `resolve(name)` возвращает
конфиг, источник и стабильный hash (canonical JSON) конфига.
- **manager** — ленивая инициализация: при первом запросе к подключению
поднимает туннель (если есть `ssh`) и создаёт драйвер. Если hash конфига
изменился со времени кеширования — dispose старого (пулы + туннель)
и пересоздание. Это даёт hot-reload настроек без перезапуска.
- **tunnel** — локальный TCP-сервер на `127.0.0.1:0` (эфемерный порт), каждое
входящее соединение форвардится через `ssh2` `forwardOut` на целевые
db host:port. Драйвер подключается к локальному endpoint.
- **driver** — интерфейс: `query`, `listDatabases`, `listTables`,
`describeTable`, `dispose`. Внутри — map пулов per database
(пул на пару connection+database), max 4 соединения на пул.
- Shutdown: SIGINT/SIGTERM/закрытие stdin → dispose всех драйверов и туннелей.
## Readonly
Серверное принуждение, не SQL-парсинг. На каждом новом коннекте пула:
- pg: `SET default_transaction_read_only = on`
- mysql: `SET SESSION TRANSACTION READ ONLY`
Блокирует DML и DDL на стороне движка. Обходится только явным `SET` в SQL —
для агентского сценария приемлемо (защита от случайной записи, не от злого умысла).
## Тулзы
Все входы — zod. Параметр `database` опционален везде, где есть, —
fallback на `database` подключения; если нет ни того ни другого → ошибка.
| Тулза | Вход | Выход |
|---|---|---|
| `list_connections` | — | `[{name, type, host, port, database, readonly, source, ssh: boolean}]`, без секретов |
| `add_connection` | полный конфиг | ok + запись в стор |
| `update_connection` | `name` + partial-конфиг | ok, патч store-записи, инвалидация кеша manager; явный `null` в поле удаляет опциональное поле (напр. `ssh: null`) |
| `remove_connection` | `name` | ok, удаление из стора, dispose ресурсов |
| `test_connection` | `name` | connect + `SELECT 1`: версия сервера, latency ms |
| `execute_sql` | `connection, sql, params?, database?, rowLimit?` | SELECT: `{columns, rows, rowCount, truncated}`; DML: `{rowCount, lastInsertId?}` |
| `list_databases` | `connection` | имена + размер в байтах |
| `list_tables` | `connection, database?, schema?` | `[{schema, name, rowEstimate}]` (mysql: schema = database) |
| `describe_table` | `connection, table, database?, schema?` | колонки (тип, nullable, default), PK, индексы, FK |
- `params` — позиционные (`$1` для pg, `?` для mysql), передаются драйверу
как параметризованный запрос.
- `rowLimit`: default 100, max 1000. При срезе `truncated: true`.
- Один SQL-стейтмент на вызов: `multipleStatements` выключен у mysql2,
у pg параметризованные запросы и так single-statement. Меньше сюрпризов.
- Ошибки — структурный текст: `[pg 42P01] relation "x" does not exist` + detail/hint
у pg. «Connection not found» перечисляет доступные имена — агент сам чинится.
- Системные БД скрыты в `list_databases` (template0/1; mysql, sys,
performance_schema, information_schema).
## Дистрибуция
- npm: package `dbmole-mcp`, `bin: { "dbmole-mcp": "dist/index.js" }`.
Подключение: `{ "command": "npx", "args": ["-y", "dbmole-mcp"] }`
- Docker: `node:22-alpine`, multi-stage build,
`docker run -i --rm -v dbmole-store:/root/.config/dbmole dbmole-mcp`
- README: оба способа + формат env/config + примеры `.mcp.json`
## Тестирование
- **Unit** (моки, без сети): мердж каскада и приоритеты, атомарность стора
и права 0600, zod-валидация (вкл. скип битых записей), форматирование
результатов и truncation, tool-хендлеры с мок-manager, lifecycle туннеля
с мок-ssh2, инвалидация по hash.
- **Интеграция** (testcontainers, требуется Docker): реальные postgres и mysql
контейнеры — полный флоу add_connection → execute_sql → list/describe →
readonly-enforcement (запись падает с ошибкой движка); sshd-контейнер →
e2e туннель до pg за SSH.
- Coverage-пороги в vitest config: >= 90% lines/functions.
- CI-разделение: `npm run test:unit` (быстрые) / `npm run test:int` (docker).
## Вне скоупа (YAGNI)
- Другие движки (sqlite, mssql) — интерфейс Driver оставляет дверь открытой
- Шифрование стора (плейнтекст 0600, как ~/.pgpass)
- HTTP/SSE транспорт
- Idle-таймауты туннелей и пулов (dispose по shutdown и инвалидации достаточно)
- Миграции формата стора