- Go 99.6%
- Dockerfile 0.4%
|
|
||
|---|---|---|
| cmd | ||
| docs | ||
| internal | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .golangci.yml | ||
| .woodpecker.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
mcp-doc-indexer
MCP-сервер для индексации и семантического поиска по документации в форматах Markdown (.md, .mdx) и plain text (.txt).
Два режима работы:
- local — однопользовательский: сервирует
./docsчерез MCP (stdio или HTTP) - shared — многопользовательский multi-tenant: SQLite, Lazy Load, fsnotify, раздельные эндпоинты Control и Data Plane
Протокол — JSON-RPC 2.0 (MCP) на всех эндпоинтах. REST не используется.
Быстрый старт (local mode)
Требования
- Go 1.22+
- Embedding-сервер (совместимый с OpenAI API
/v1/embeddings)
Запуск
# из исходников (локально)
go run ./cmd/mcp-doc-indexer
# из репозитория (Go 1.21+)
go run git.ymnuktech.ru/ymnuk/mcp-doc-indexer/cmd/mcp-doc-indexer@latest
# установка как утилиты
go install git.ymnuktech.ru/ymnuk/mcp-doc-indexer/cmd/mcp-doc-indexer@latest
mcp-doc-indexer
# сборка и запуск
go build -o mcp-doc-indexer ./cmd/mcp-doc-indexer
./mcp-doc-indexer
# HTTP вместо stdio
./mcp-doc-indexer --transport http
# с кастомным embedding-сервером
./mcp-doc-indexer --embedding-url http://localhost:11434/v1/embeddings
Пример MCP-запроса (stdio)
{"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"как настроить CI"}}}
Подключение к MCP-агентам (Cline, Roo, Claude Desktop)
cline_mcp_settings.json / mcp.json:
{
"mcpServers": {
"doc-indexer": {
"command": "/path/to/mcp-doc-indexer",
"args": ["--mode", "local", "--transport", "stdio"],
"env": {
"EMBEDDING_URL": "http://localhost:8000"
}
}
}
}
Для разработки — через go run:
{
"mcpServers": {
"doc-indexer": {
"command": "go",
"args": ["run", "./cmd/mcp-doc-indexer", "--mode", "local", "--transport", "stdio"]
}
}
}
Архитектура
┌──────────────────────────────────┐
│ HTTP Router │
│ (http.ServeMux) │
│ │
│ /mcp ────────────────────────────│───▶ Control Server
│ /mcp/{session_id} ──────────────│───▶ Session Server
└──────────────────────────────────┘
│
┌────────────────┼──────────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Control MCP Server │ │ Session MCP Server │
│ (один на весь сервер) │ │ (свой для каждой │
│ │ │ зарегистрированной │
│ • register │ │ сессии) │
│ • unregister │ │ │
│ • list │ │ • search_docs │
└─────────────────────────┘ │ • get_content │
└─────────────────────────┘
│
▼
┌─────────────────────┐
│ session.Session │
│ • Load / Unload │
│ • Search │
│ • GetContent │
│ • State Machine │
│ • fsnotify │
└─────────────────────┘
Режимы
| local | shared | |
|---|---|---|
| Путь | ./docs (жёстко) |
--path-root + путь из register |
| БД | SQLite (./docs/docs.db) |
SQLite (--db-path) |
| Lazy Load | Нет (всегда Loaded) | Да (Loaded ↔ Unloaded) |
| fsnotify | Да | Per-session |
| Сессии | Одна, ID="local" | Множественные |
| Control (register/unreg/list) | Недоступны | Доступны на POST /mcp |
| Data (search_docs/get_content) | Доступны на POST /mcp |
Доступны на POST /mcp/{session_id} |
| Транспорт | stdio (default) или http | Только http |
Инструменты MCP
| Инструмент | local | shared | Эндпоинт (shared) | Описание |
|---|---|---|---|---|
register |
— | да | POST /mcp |
Создать сессию для директории |
unregister |
— | да | POST /mcp |
Удалить сессию |
list |
— | да | POST /mcp |
Список активных сессий |
search_docs |
да | да | POST /mcp/{session_id} |
Семантический поиск |
get_content |
да | да | POST /mcp/{session_id} |
Получить полный текст чанка |
register
Создаёт новую сессию, индексирует файлы (.md, .mdx, .txt) в указанной директории и создаёт независимый MCP-сервер для Data Plane по адресу POST /mcp/{session_id}. Путь задаётся относительно --path-root. Синхронная операция — ответ только после завершения индексации.
- Где доступен: только shared,
POST /mcp - Вход:
path(string, обяз.) — путь относительно path-root;description(string, опц.) — описание сессии - Выход:
session_id(string) — UUID сессии;path(string) — абсолютный путь;chunks_count(int) — число проиндексированных чанков - Особенности: если директория не существует — создаётся (
os.MkdirAll); выход за--path-rootчерез..запрещён; директория резолвится черезfilepath.Join+filepath.Cleanс проверкой префикса; после регистрации клиент может подключиться кPOST /mcp/{session_id}для поиска
unregister
Удаляет сессию: останавливает fsnotify-наблюдение, удаляет соответствующий Session MCP Server (эндпоинт /mcp/{session_id} перестаёт отвечать), сбрасывает dirty-данные в SQLite, чистит RAM.
- Где доступен: только shared,
POST /mcp - Вход:
session_id(string, обяз.) - Выход:
text: "unregistered" - Ошибка:
-32000если сессия не найдена
list
Возвращает метрики по всем зарегистрированным сессиям.
- Где доступен: только shared,
POST /mcp - Вход: не требуется
- Выход: массив
sessionsс полями:session_id,path,description,state(Loaded/Unloaded),chunks_count,last_access(Unix timestamp)
search_docs
Семантический поиск по проиндексированным документам. Использует dot-product между эмбеддингом запроса и эмбеддингами чанков. В local mode при первом запуске индексирует все файлы и сохраняет результат в SQLite; при повторных запусках загружает данные из кеша (проверка mtime). В shared mode при первом запросе к сессии в состоянии Unloaded выполняет Lazy Load (автоматическая загрузка из SQLite + проверка изменений файлов по mtime).
- Где доступен: local (
POST /mcp) и shared (POST /mcp/{session_id}) - Вход:
query(string, обяз.) — текст запроса;limit(int, опц., default 10) — максимум результатов;min_score(float, опц., default 0.4) — минимальный порог схожести - Выход: массив результатов с полями:
text(string) — превью чанка;score(float) — косинусная близость;file(string) — путь к исходному файлу;section_path(string, опц.) — иерархия заголовков;chunk_id(string) — ID для get_content - Особенности: в shared mode сессия идентифицируется через URL path (
/mcp/{session_id}); в local mode используется единственная сессия; еслиmin_score == 0, применяется значение--min-scoreиз флагов
get_content
Возвращает полный текст фрагмента документа по его ID. Источник данных: RAM (если сессия Loaded) → SQLite (если Unloaded).
- Где доступен: local (
POST /mcp) и shared (POST /mcp/{session_id}) - Вход:
chunk_id(string, обяз.) - Выход:
text(string) — полный текст чанка;line_start/line_end(int) — строки в исходном файле;char_start/char_end(int) — смещения в символах
State Machine (shared mode)
[Start] → register() → StateLoaded (RAM + fsnotify)
│
(IDLE_TIMEOUT)
▼
StateUnloaded (RAM cleared, fsnotify stopped)
│
(incoming search_docs)
▼
Lazy Load → mtime-diff sync → StateLoaded
Конфигурация
CLI-флаги
| Флаг | По умолчанию | Описание |
|---|---|---|
--mode |
local |
local или shared |
--transport |
stdio |
stdio или http |
--port |
8080 |
HTTP порт |
--host |
0.0.0.0 |
HTTP адрес |
--path-root |
./projects |
Корень для путей сессий (shared) |
--db-path |
./projects/projects.db |
Файл SQLite (shared/local). Для local по умолчанию ./docs/docs.db |
--idle-timeout |
30m |
Таймаут простоя сессии (shared) |
--embedding-url |
http://embedding:8000 |
URL embedding-сервера |
--embedding-model |
default |
Модель эмбеддингов |
--min-score |
0.4 |
Порог схожести по умолчанию (0.1–1.0) |
--version |
Показать версию |
Все флаги также поддерживаются через переменные окружения (например, MODE=shared).
Docker
# Сборка
docker build --build-arg VERSION=v0.1.0 -t mcp-doc-indexer .
# Запуск (local mode, HTTP)
docker run -p 8080:8080 \
-v ./docs:/docs:ro \
mcp-doc-indexer -- --transport http
# Запуск (shared mode)
docker run -p 8080:8080 \
-v ./projects:/projects \
mcp-doc-indexer -- \
--mode shared \
--transport http
Docker Compose
# Запуск с embedding-сервером
docker compose up
# с кастомной директорией docs
DOCS_DIR=/path/to/docs docker compose up
Результаты тестирования
На технической документации из 9 Markdown-файлов.
Сравнение размера чанка (maxChunkSize)
Параметры: дробление по всем заголовкам h1–h6, модель default.
| Метрика | 600 символов | 1400 символов |
|---|---|---|
| Количество чанков | 87 | 70 |
| Средний размер чанка | ~280 chars | 311 chars |
| Максимальный score | 0.494 | 0.590 |
| Минимальный score | 0.276 | 0.360 |
Сравнение дробления по заголовкам
Параметры: maxChunkSize = 1400, модель default.
| Запрос | Все h дробят | h3+ мержатся |
|---|---|---|
| Архитектура MCP сервера | 0.590 ✅ | 0.445 |
| CI настройка и сборка | 0.494 | 0.408 |
| Семантический поиск | 0.443 | 0.454 |
| Как запустить тесты | 0.439 | 0.424 |
| Конфигурация Docker | 0.401 | 0.363 |
| Лицензия проекта | 0.298 | 0.353 |
Вывод: дробление по всем заголовкам даёт более точные результаты (5 из 6 запросов). Каждый раздел документирует одну тему — отдельный чанк даёт чистый семантический сигнал. Оптимальная конфигурация: maxChunkSize = 1400 + дробление по всем заголовкам.
SQLite-кеш: холодный старт vs загрузка из БД
На той же документации (72 чанка, embedding-сервер llm-simple-api).
| Запуск | Время | Результаты поиска |
|---|---|---|
| 1-й (cold start) | ~4 мин | 6/6 запросов |
| 2-й (из SQLite) | 4 мс | Идентичны ✅ |
При повторном запуске сервер загружает 72 чанка из SQLite за миллисекунды, проверяет mtime файлов и пропускает полную индексацию. Эмбеддинги не пересчитываются — результаты поиска идентичны.
Ускорение: ~60000× (4 мин → 4 мс).
Сбросить кеш: demo --reset или удалить ./docs/docs.db.
Разработка
Сборка
go build -o mcp-doc-indexer ./cmd/mcp-doc-indexer
Версионирование
go build -ldflags "-X git.ymnuktech.ru/ymnuk/mcp-doc-indexer/internal/version.Version=v0.1.0" \
-o mcp-doc-indexer ./cmd/mcp-doc-indexer
Тесты
# unit + integration (без внешних зависимостей)
go test -race ./...
# покрытие
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
Мониторинг
При HTTP-транспорте доступны два эндпоинта:
GET /health— JSON со статусом сервера, режимом, uptime, количеством сессий и чанковGET /metrics— Prometheus-метрики в формате text/plain
Метрики (mcp_doc_indexer_search_total, mcp_doc_indexer_search_duration_seconds, mcp_doc_indexer_sessions_active и др.) автоматически регистрируются в глобальном реестре Prometheus.
Лицензия
MIT