593 lines
15 KiB
Markdown
593 lines
15 KiB
Markdown
# MCP: описание работы, структуры и тулзов
|
||
|
||
Документ описывает MCP endpoint сервиса `miem-employees` по текущей реализации в `app/mcp.py`.
|
||
|
||
## Где находится MCP
|
||
|
||
- FastAPI router: `app.mcp.router`
|
||
- Подключение к приложению: `app/main.py`
|
||
- HTTP endpoint: `POST /mcp`
|
||
- Локально при обычном запуске API: `http://localhost:8000/mcp`
|
||
- В Docker Compose через отдельный сервис `mcp`: `http://localhost:8001/mcp`
|
||
- Авторизация на уровне приложения: отсутствует. Заголовок `Authorization` не проверяется и не влияет на ответ.
|
||
|
||
Если доступ к MCP нужно ограничить, это должно делаться внешним контуром: bind на localhost, VPN, firewall, reverse proxy или отдельная сетевая политика.
|
||
|
||
## Протокол
|
||
|
||
Endpoint принимает JSON-RPC 2.0 over HTTP.
|
||
|
||
Общий формат запроса:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "tools/list",
|
||
"params": {}
|
||
}
|
||
```
|
||
|
||
Общий формат успешного ответа:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"result": {}
|
||
}
|
||
```
|
||
|
||
Общий формат ошибки:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"error": {
|
||
"code": -32601,
|
||
"message": "Method not found"
|
||
}
|
||
}
|
||
```
|
||
|
||
Поддерживаемая версия MCP-протокола:
|
||
|
||
```text
|
||
2024-11-05
|
||
```
|
||
|
||
Имя сервиса:
|
||
|
||
```text
|
||
miem-employees
|
||
```
|
||
|
||
Версия сервера берется из `app.version.BACKEND_VERSION`.
|
||
|
||
## Поддерживаемые JSON-RPC методы
|
||
|
||
### initialize
|
||
|
||
Возвращает метаданные MCP-сервера и capabilities.
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "initialize",
|
||
"params": {}
|
||
}
|
||
```
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"result": {
|
||
"protocolVersion": "2024-11-05",
|
||
"serverInfo": {
|
||
"name": "miem-employees",
|
||
"version": "0.5.0"
|
||
},
|
||
"capabilities": {
|
||
"tools": {}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### tools/list
|
||
|
||
Возвращает список доступных tools с JSON Schema для аргументов.
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "tools/list",
|
||
"params": {}
|
||
}
|
||
```
|
||
|
||
Ответ содержит массив `result.tools`.
|
||
|
||
### tools/call
|
||
|
||
Вызывает один tool по имени.
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "tools/call",
|
||
"params": {
|
||
"name": "search_employees",
|
||
"arguments": {
|
||
"query": "Сергеев",
|
||
"limit": 20
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Ответ tool всегда заворачивается в MCP content-массив:
|
||
|
||
```json
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"result": {
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "{\"items\":[]}"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
Поле `text` содержит сериализованный JSON с `ensure_ascii=false`. Клиент должен распарсить это поле как JSON, если ему нужна структурированная нагрузка.
|
||
|
||
## Ошибки
|
||
|
||
- Неизвестный JSON-RPC метод: `code = -32601`, `message = "Method not found"`.
|
||
- Исключения при обработке tool: `code = -32000`, `message` содержит текст исключения.
|
||
- Если сущность не найдена внутри отдельных tools, HTTP и JSON-RPC ответ остаются успешными, а полезная нагрузка содержит `{"error": "not_found"}`.
|
||
|
||
## Источники данных
|
||
|
||
MCP читает данные из основной базы через SQLAlchemy session из `app.db.get_db`.
|
||
|
||
Основные таблицы и модели:
|
||
|
||
- `employees`: текущая карточка сотрудника, статус, профиль, `current_data`, checksum.
|
||
- `crawl_runs`: история запусков парсинга.
|
||
- `crawl_run_employee_changes`: детальные изменения сотрудников в рамках запуска.
|
||
- `crawl_errors`: ошибки парсинга в рамках запуска.
|
||
- `dataset_versions`: версии полного набора сотрудников.
|
||
- `dataset_version_items`: состав конкретной версии набора сотрудников.
|
||
|
||
## Общая структура employee payload
|
||
|
||
Краткая карточка сотрудника:
|
||
|
||
```json
|
||
{
|
||
"profile_key": "staff:avsergeev",
|
||
"profile_id": "avsergeev",
|
||
"full_name": "Сергеев Алексей Викторович",
|
||
"status": "active",
|
||
"canonical_url": "https://www.hse.ru/staff/avsergeev",
|
||
"last_seen_at": "2026-05-14T10:00:00+00:00",
|
||
"dismissed_at": null
|
||
}
|
||
```
|
||
|
||
В sync payload дополнительно отдается `checksum`.
|
||
|
||
Полная карточка дополнительно содержит:
|
||
|
||
```json
|
||
{
|
||
"data": {
|
||
"contacts": {},
|
||
"sections": []
|
||
}
|
||
}
|
||
```
|
||
|
||
`data` соответствует распарсенному JSON профиля сотрудника. Внутри `sections` могут быть секции с публикациями, курсами, ВКР, таблицами, ссылками и произвольными текстовыми блоками.
|
||
|
||
## Tools
|
||
|
||
### get_service_info
|
||
|
||
Назначение: вернуть метаданные сервиса, список tools и текущую версию набора сотрудников.
|
||
|
||
Аргументы: отсутствуют.
|
||
|
||
Возвращает:
|
||
|
||
```json
|
||
{
|
||
"service_name": "miem-employees",
|
||
"backend_version": "0.5.0",
|
||
"protocolVersion": "2024-11-05",
|
||
"tools": [],
|
||
"dataset": {
|
||
"hash": "sha256",
|
||
"previous_hash": "sha256 или null",
|
||
"created_at": "2026-05-14T10:00:00+00:00",
|
||
"crawl_run_id": 123,
|
||
"employee_count": 100,
|
||
"active_count": 95,
|
||
"dismissed_count": 5
|
||
}
|
||
}
|
||
```
|
||
|
||
Особенность: перед ответом сервис создает актуальную `dataset_version`, если текущий набор сотрудников еще не имеет версии.
|
||
|
||
### sync_employees
|
||
|
||
Назначение: синхронизировать клиентский кэш сотрудников по hash набора данных.
|
||
|
||
Аргументы:
|
||
|
||
```json
|
||
{
|
||
"client_hash": "sha256 или null",
|
||
"include_data": true
|
||
}
|
||
```
|
||
|
||
- `client_hash`: hash версии, которая уже есть у клиента. Если не передан, отдается полный snapshot.
|
||
- `include_data`: управляет включением полного `data` в карточки сотрудников. По умолчанию `true`.
|
||
|
||
Полный ответ без `client_hash`:
|
||
|
||
```json
|
||
{
|
||
"mode": "full",
|
||
"from_hash": null,
|
||
"to_hash": "current-sha256",
|
||
"dataset": {},
|
||
"items": []
|
||
}
|
||
```
|
||
|
||
Если клиентский hash совпадает с текущим:
|
||
|
||
```json
|
||
{
|
||
"mode": "delta",
|
||
"from_hash": "current-sha256",
|
||
"to_hash": "current-sha256",
|
||
"dataset": {},
|
||
"changes": {
|
||
"added": [],
|
||
"updated": [],
|
||
"dismissed": [],
|
||
"removed": []
|
||
}
|
||
}
|
||
```
|
||
|
||
Если `client_hash` неизвестен серверу:
|
||
|
||
```json
|
||
{
|
||
"mode": "full",
|
||
"from_hash": "missing",
|
||
"to_hash": "current-sha256",
|
||
"dataset": {},
|
||
"items": [],
|
||
"reason": "unknown_client_hash"
|
||
}
|
||
```
|
||
|
||
Если `client_hash` найден и отличается от текущего:
|
||
|
||
```json
|
||
{
|
||
"mode": "delta",
|
||
"from_hash": "old-sha256",
|
||
"to_hash": "current-sha256",
|
||
"dataset": {},
|
||
"changes": {
|
||
"added": [],
|
||
"updated": [],
|
||
"dismissed": [],
|
||
"removed": []
|
||
}
|
||
}
|
||
```
|
||
|
||
Логика delta:
|
||
|
||
- `added`: сотрудник появился в новой версии.
|
||
- `updated`: изменился checksum или статус, и сотрудник активен.
|
||
- `dismissed`: сотрудник есть в новой версии, но получил статус `dismissed`.
|
||
- `removed`: `profile_key` был в старой версии, но отсутствует в новой.
|
||
|
||
Hash набора считается по отсортированному списку `{profile_key, status, checksum}`.
|
||
|
||
### search_employees
|
||
|
||
Назначение: найти сотрудников по ФИО или canonical URL.
|
||
|
||
Аргументы:
|
||
|
||
```json
|
||
{
|
||
"query": "Сергеев",
|
||
"status": "active",
|
||
"limit": 20
|
||
}
|
||
```
|
||
|
||
- `query`: обязательный по schema, но в коде пустая строка означает поиск без текстового фильтра.
|
||
- `status`: опционально, только `active` или `dismissed`.
|
||
- `limit`: максимум 100, по умолчанию 20.
|
||
|
||
Возвращает массив кратких employee payload без `data`:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"profile_key": "staff:avsergeev",
|
||
"profile_id": "avsergeev",
|
||
"full_name": "Сергеев Алексей Викторович",
|
||
"status": "active",
|
||
"canonical_url": "https://www.hse.ru/staff/avsergeev",
|
||
"last_seen_at": "2026-05-14T10:00:00+00:00",
|
||
"dismissed_at": null
|
||
}
|
||
]
|
||
```
|
||
|
||
### get_employee
|
||
|
||
Назначение: получить одну карточку сотрудника.
|
||
|
||
Аргументы:
|
||
|
||
```json
|
||
{
|
||
"profile_id_or_url": "avsergeev"
|
||
}
|
||
```
|
||
|
||
Поиск выполняется по:
|
||
|
||
- `profile_key`
|
||
- `profile_id`
|
||
- точному `canonical_url`
|
||
- частичному совпадению `canonical_url`
|
||
|
||
Возвращает полный employee payload с `data`.
|
||
|
||
Если сотрудник не найден:
|
||
|
||
```json
|
||
{
|
||
"error": "not_found"
|
||
}
|
||
```
|
||
|
||
### list_employee_publications
|
||
|
||
Назначение: вернуть публикации сотрудника из распарсенных секций профиля.
|
||
|
||
Аргументы:
|
||
|
||
```json
|
||
{
|
||
"profile_id_or_url": "avsergeev"
|
||
}
|
||
```
|
||
|
||
Сервис ищет секции `current_data.sections` с `type = "publications"` и объединяет массивы `publications`.
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"employee": {},
|
||
"items": [
|
||
{
|
||
"title": "Название публикации",
|
||
"text": "Полное описание",
|
||
"url": "https://..."
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Если сотрудник или данные профиля отсутствуют:
|
||
|
||
```json
|
||
{
|
||
"items": []
|
||
}
|
||
```
|
||
|
||
### list_employee_courses
|
||
|
||
Назначение: вернуть курсы преподавания сотрудника из распарсенных секций профиля.
|
||
|
||
Аргументы:
|
||
|
||
```json
|
||
{
|
||
"profile_id_or_url": "avsergeev"
|
||
}
|
||
```
|
||
|
||
Сервис ищет секции `current_data.sections` с `type = "courses_by_year"` и объединяет массивы `courses`.
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"employee": {},
|
||
"items": [
|
||
{
|
||
"title": "Название курса",
|
||
"url": "https://..."
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Если сотрудник или данные профиля отсутствуют:
|
||
|
||
```json
|
||
{
|
||
"items": []
|
||
}
|
||
```
|
||
|
||
### get_crawl_status
|
||
|
||
Назначение: вернуть последний запуск парсинга.
|
||
|
||
Аргументы: отсутствуют.
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"id": 123,
|
||
"status": "completed",
|
||
"source_url": "https://miem.hse.ru/persons",
|
||
"started_at": "2026-05-14T10:00:00+00:00",
|
||
"finished_at": "2026-05-14T10:10:00+00:00",
|
||
"found_count": 100,
|
||
"parsed_count": 98,
|
||
"error_count": 2,
|
||
"dismissed_count": 1
|
||
}
|
||
```
|
||
|
||
Если запусков еще не было:
|
||
|
||
```json
|
||
{
|
||
"status": "never_run"
|
||
}
|
||
```
|
||
|
||
### get_crawl_run_details
|
||
|
||
Назначение: вернуть детальную информацию по конкретному запуску парсинга: summary, изменения сотрудников и ошибки.
|
||
|
||
Аргументы:
|
||
|
||
```json
|
||
{
|
||
"run_id": 123
|
||
}
|
||
```
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"id": 123,
|
||
"source_url": "https://miem.hse.ru/persons",
|
||
"status": "completed",
|
||
"status_display": "Завершен",
|
||
"started_at": "2026-05-14T10:00:00+00:00",
|
||
"finished_at": "2026-05-14T10:10:00+00:00",
|
||
"started_display": "14.05.2026 13:00",
|
||
"finished_display": "14.05.2026 13:10",
|
||
"found_count": 100,
|
||
"parsed_count": 98,
|
||
"new_count": 3,
|
||
"error_count": 2,
|
||
"dismissed_count": 1,
|
||
"processed_count": 100,
|
||
"progress_percent": 100.0,
|
||
"message": null,
|
||
"changes_detail_available": true,
|
||
"changes": {
|
||
"new": [],
|
||
"missing_from_source": [],
|
||
"dismissed": []
|
||
},
|
||
"errors": []
|
||
}
|
||
```
|
||
|
||
Если запуск не найден:
|
||
|
||
```json
|
||
{
|
||
"error": "not_found"
|
||
}
|
||
```
|
||
|
||
## Примеры curl
|
||
|
||
Список tools:
|
||
|
||
```bash
|
||
curl http://localhost:8001/mcp \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||
```
|
||
|
||
Поиск сотрудника:
|
||
|
||
```bash
|
||
curl http://localhost:8001/mcp \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_employees","arguments":{"query":"Сергеев","limit":5}}}'
|
||
```
|
||
|
||
Полная синхронизация:
|
||
|
||
```bash
|
||
curl http://localhost:8001/mcp \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sync_employees","arguments":{"include_data":false}}}'
|
||
```
|
||
|
||
Delta-синхронизация:
|
||
|
||
```bash
|
||
curl http://localhost:8001/mcp \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_employees","arguments":{"client_hash":"known-sha256","include_data":true}}}'
|
||
```
|
||
|
||
## Как MCP используется клиентом
|
||
|
||
1. Клиент вызывает `initialize` и проверяет `protocolVersion`.
|
||
2. Клиент вызывает `tools/list`, чтобы получить актуальный список tools и input schemas.
|
||
3. Для поиска и точечных запросов клиент вызывает `tools/call` с `search_employees`, `get_employee`, `list_employee_publications`, `list_employee_courses`, `get_crawl_status` или `get_crawl_run_details`.
|
||
4. Для локального кэша клиент вызывает `get_service_info` или `sync_employees`.
|
||
5. Клиент хранит последний `dataset.hash`.
|
||
6. При следующей синхронизации клиент передает hash как `client_hash`.
|
||
7. Сервер возвращает пустую delta, delta с изменениями или полный snapshot, если hash неизвестен.
|
||
|
||
## Важные особенности реализации
|
||
|
||
- MCP endpoint read-only: tools не запускают парсинг и не меняют сотрудников напрямую.
|
||
- `get_service_info` и `sync_employees` могут создать новую запись `dataset_versions`, если состояние сотрудников изменилось и новой версии еще нет.
|
||
- Все tool payloads возвращаются как JSON-строка внутри `content[0].text`.
|
||
- `search_employees` ищет через `ilike` по `full_name` и `canonical_url`.
|
||
- `get_employee` допускает частичный URL, поэтому строка `133709486` может найти `https://www.hse.ru/org/persons/133709486`.
|
||
- Временные значения сериализуются через `isoformat()`, display-поля для админских payload формируются в часовом поясе `Europe/Moscow`.
|