418 lines
23 KiB
Markdown
Executable File
418 lines
23 KiB
Markdown
Executable File
# balda-executor-mcp — MCP-сервер делегирования задач (шаг 5 Фазы 4)
|
||
|
||
> Создан 2026-09-26. Шаг 5 плана [[personal/plans/balda-mm-phase4-agents]].
|
||
> Статус: **собран, задеплоен на Kraken, образ `0.2.0`; конфиг Балды — `type: http` +
|
||
> `obsidian: sse`. Балда стартует без ошибок и отвечает в чате.**
|
||
> 🔴 **Блокер:** MCP-инструменты не попадают в сессию — корень в снапшоте каталога
|
||
> (`state.db`), см. **§10g**. Ждёт решения Alex (вариант A/B).
|
||
> Связка: Балда (Kraken) → MCP → мост (Mac :8790) → контейнер-исполнитель.
|
||
|
||
---
|
||
|
||
## 1. Зачем отдельный MCP-сервер
|
||
|
||
Балда **не может** сама запустить CLI на Mac: Mac за NAT, а `runtime.mcp_servers` —
|
||
это либо stdio-процесс внутри контейнера Балды, либо HTTP по сети.
|
||
|
||
Схема выбрана такая:
|
||
|
||
```
|
||
Балда (Kraken, контейнер balda-mm)
|
||
│ runtime.mcp_servers.executor-mcp
|
||
▼
|
||
MCP-сервер balda-executor-mcp (Kraken, контейнер в mattermost_mm-net) ← HTTP
|
||
│ X-Bridge-Secret
|
||
▼
|
||
мост agents-bridge (Mac, 192.168.1.57:8790) ← обычный HTTP по LAN
|
||
│ docker exec
|
||
▼
|
||
контейнер balda-executor (Mac) → CLI claude/codex
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Расположение и файлы
|
||
|
||
| Что | Путь |
|
||
|---|---|
|
||
| Проект | `~/Developer/balda-executor-mcp/` |
|
||
| Сервер | `server.py` (stdio + HTTP в одном файле) |
|
||
| Образ | `balda-executor-mcp:0.1.0` (python:3.12-slim-bookworm) |
|
||
| Dockerfile | `~/Developer/balda-executor-mcp/Dockerfile` |
|
||
| Compose (Kraken) | `/home/kraken/mcp/docker-compose.yml` |
|
||
| Контейнер на Kraken | `balda-executor-mcp` (сеть `mattermost_mm-net`) |
|
||
| Порядковый порт | `:8890` внутри docker-сети, **наружу не публикуется** |
|
||
|
||
---
|
||
|
||
## 3. Инструменты MCP
|
||
|
||
| Инструмент | Аргументы | Что делает |
|
||
|---|---|---|
|
||
| `executor_run` | `prompt` (обяз.), `provider?` (`claude`/`codex`), `idempotency_key?` | ставит задачу, возвращает `task_id` немедленно |
|
||
| `executor_status` | `task_id` | статус + stdout/stderr/exit_code |
|
||
| `executor_health` | — | живость моста и контейнера-исполнителя |
|
||
|
||
Ошибки возвращаются как `isError: true` с человеческим текстом:
|
||
- 429 → «Исполнитель занят (лимит одновременных задач)»
|
||
- 404 → «задача … не найдена»
|
||
- прочие → «мост вернул N: …»
|
||
|
||
---
|
||
|
||
## 4. Переменные окружения
|
||
|
||
| Ключ | Смысл |
|
||
|---|---|
|
||
| `BRIDGE_URL` | базовый URL моста (например `http://192.168.1.57:8790`) |
|
||
| `BRIDGE_SECRET` | общий секрет, уходит в `X-Bridge-Secret` |
|
||
| `BRIDGE_TIMEOUT` | таймаут обычных вызовов (по умолчанию 60 с) |
|
||
| `MCP_HOST` / `MCP_PORT` | адрес HTTP-режима (по умолчанию `127.0.0.1:8890`) |
|
||
|
||
---
|
||
|
||
## 5. Два режима запуска
|
||
|
||
```bash
|
||
# stdio (Балда сама запускает процесс)
|
||
python3 server.py
|
||
|
||
# HTTP (отдельный контейнер, Балда ходит по URL)
|
||
python3 server.py --http --host 0.0.0.0 --port 8890
|
||
```
|
||
|
||
⚠️ **Использовать надо HTTP-режим.** Причины — §7.
|
||
|
||
---
|
||
|
||
## 6. Приёмка (2026-09-26) — что именно проверено
|
||
|
||
| Проверка | Результат |
|
||
|---|---|
|
||
| stdio: `initialize` → `tools/list` | 3 инструмента возвращены ✅ |
|
||
| stdio: `tools/call` health | живые данные моста ✅ |
|
||
| stdio: 429 / 500 / 404 | корректные `isError` с текстом ✅ |
|
||
| образ: тот же handshake внутри контейнера | ✅ |
|
||
| образ на Kraken: загрузка | `Loaded image: balda-executor-mcp:0.1.0` ✅ |
|
||
| Kraken → мост по LAN (curl из docker-сети) | `status: ok` ✅ |
|
||
| MCP в docker-сети → `executor_health` через мост | живые данные Mac ✅ |
|
||
| контейнер `balda-executor-mcp` | Up ✅ |
|
||
|
||
Тестовые скрипты: `/tmp/test-mcp-server.sh` (мок моста + stdio-проба),
|
||
`/tmp/kraken-test-mcp.sh` (ручной handshake из docker-сети Kraken).
|
||
|
||
---
|
||
|
||
## 7. 🔴 Питфолл: stdio-вариант нерабочий в этом окружении
|
||
|
||
Изначальный план был `type: stdio` + `cmd: ["docker", "run", ...]` — то есть Балда
|
||
сама запускает MCP-контейнер. **Это не работает по двум причинам:**
|
||
|
||
1. **`docker.sock` в контейнер Балды не смонтирован.** Проверено:
|
||
`docker inspect balda-mm` даёт только три тома (config/state/workspace) — сокета нет.
|
||
Значит `docker run` изнутри Балды невозможен.
|
||
2. **В образе Балды нет shell.** `docker exec balda-mm sh -c ...` →
|
||
`OCI runtime exec failed: exec: "sh": executable file not found in $PATH`
|
||
(образ из `scratch`). Отладка и запуск подпроцессов через shell недоступны.
|
||
|
||
**Решение:** MCP-сервер вынести в **отдельный контейнер** с HTTP-транспортом.
|
||
Конфиг Балды тогда:
|
||
|
||
```yaml
|
||
runtime:
|
||
mcp_servers:
|
||
executor-mcp:
|
||
type: http
|
||
url: "http://balda-executor-mcp:8890/mcp"
|
||
|
||
balda:
|
||
mcp_servers:
|
||
- executor-mcp
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 🔴 Питфолл: `--chmod=644` для файла, запускаемого не-root
|
||
|
||
Dockerfile изначально делал `COPY server.py /app/server.py` + `USER mcpsvc` → при
|
||
запуске: `python3: can't open file '/app/server.py': [Errno 13] Permission denied`.
|
||
|
||
Фикс: `COPY --chmod=644 server.py /app/server.py`. Не-root пользователь должен уметь
|
||
**читать** код, иначе контейнер падает ещё до handshake.
|
||
|
||
Симптом обманчив — выглядит как поломка образа, а не как права файла.
|
||
|
||
---
|
||
|
||
## 9. Питфолл: `docker-compose` на Mac, `docker compose` на Kraken
|
||
|
||
| Хост | Как вызывать |
|
||
|---|---|
|
||
| Mac (Colima) | **`docker-compose`** (v5.1.3, отдельный бинарь) |
|
||
| Kraken | **`docker compose`** (плагин v5.1.3); `docker-compose` → `command not found` |
|
||
|
||
Одна и та же версия, но на разных хостах **разный способ вызова**. Скрипты деплоя
|
||
для двух хостов нельзя копировать без правки этой строки.
|
||
|
||
---
|
||
|
||
## 10. HTTP-transport: ручная проба (streamable)
|
||
|
||
```bash
|
||
docker run --rm --network mattermost_mm-net curlimages/curl:latest \
|
||
-s --max-time 10 -X POST http://balda-executor-mcp:8890/mcp \
|
||
-H "Content-Type: application/json" \
|
||
-H "Accept: application/json, text/event-stream" \
|
||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' -i
|
||
```
|
||
|
||
⚠️ Заголовок `Accept: text/event-stream` **обязателен** — сервер отвечает SSE.
|
||
Без него — отказ. Ответ отдаётся как `event: message\ndata: {...}`.
|
||
|
||
`GET /healthz` у MCP-сервера отдаёт `{"status":"ok","bridge_url":...,"secret_set":true}`.
|
||
|
||
---
|
||
|
||
## 10a. 🔴 Питфолл: `from __future__ import annotations` ломает FastAPI-эндпоинт
|
||
|
||
Симптом: `initialize` возвращал **422 `{"type":"missing","loc":["query","request"],"msg":"Field required"}`**,
|
||
и `mcp-session-id` в ответе отсутствовал — значит handshake не начинался вовсе.
|
||
|
||
**Причина:** модуль объявлен с `from __future__ import annotations`, поэтому аннотация
|
||
`async def mcp(request: Request)` превращается в **строку** и FastAPI не распознаёт
|
||
её как объект запроса → считает `request` обычным query-параметром.
|
||
|
||
**Фикс:** убрать `from __future__ import annotations` из модуля, где объявлены
|
||
FastAPI-роуты. Никакие обходы (`request: Request = None`, ручная проверка `isinstance`,
|
||
импорт `Request as FastAPIRequest`) **не помогают** — только удаление строки.
|
||
|
||
📌 Правило: файл с FastAPI-эндпоинтами не должен использовать отложенные аннотации.
|
||
Если `from __future__ import annotations` нужен для остального кода — выносить роуты
|
||
в отдельный модуль.
|
||
|
||
## 10b. 🔴 Питфолл: `command:` в compose не переопределяет ENTRYPOINT
|
||
|
||
Образ имеет `ENTRYPOINT ["python3", "/app/server.py"]`. В compose было
|
||
`command: ["python3", "/app/server.py", "--http", ...]` → аргументы **добавляются**
|
||
к ENTRYPOINT, и получается `python3 /app/server.py python3 /app/server.py --http ...`:
|
||
|
||
```
|
||
server.py: error: unrecognized arguments: python3 /app/server.py
|
||
```
|
||
|
||
**Фикс:** в `command:` указывать **только флаги**, без повторения ENTRYPOINT:
|
||
|
||
```yaml
|
||
command: ["--http", "--host", "0.0.0.0", "--port", "8890"]
|
||
```
|
||
|
||
Симптом коварен: контейнер уходит в `Restarting (2)`, а в логах — usage от argparse.
|
||
|
||
## 10c. Питфолл: `uvicorn`/`fastapi` нужны в образе для `--http`
|
||
|
||
Образ изначально ставил только `httpx` (для stdio-режима). Запуск с `--http` падал:
|
||
`ModuleNotFoundError: No module named 'uvicorn'`. Оба режима живут в одном файле,
|
||
поэтому `requirements.txt` обязан содержать `httpx`, `uvicorn` **и** `fastapi`.
|
||
|
||
## 10d. Питфолл: точка монтирования отсутствует в docker-сети
|
||
|
||
`http://balda-executor-mcp:8890` резолвится только внутри `mattermost_mm-net`.
|
||
Проверка извне сети даёт «мост недоступен» с `httpx.HTTPError`. Прогонять пробу
|
||
через `docker run --network mattermost_mm-net curlimages/curl:latest`.
|
||
|
||
⚠️ При ручной пробе через `docker run -e BRIDGE_SECRET=...` редакция секретов Hermes
|
||
может подставить `***` (3 символа) → мост вернёт 401, а `r.json()` упадёт с
|
||
`JSONDecodeError: Expecting value: line 1 column 1`. Передавать секрет через
|
||
`--env-file`, а не через `-e` в командной строке.
|
||
|
||
---
|
||
|
||
## 10e. 🔴🔴 ГЛАВНЫЙ ПИТФОЛЛ: тип транспорта — `http`, а НЕ `streamable-http`
|
||
|
||
Самый дорогой урок этой сессии. Два неверных значения подряд, оба падают **по-разному**:
|
||
|
||
| Что писал | Ошибка | Симптом |
|
||
|---|---|---|
|
||
| `type: http` (первый раз — как «правильный») | ❌ на самом деле ВЕРНО, но падало из-за старого образа 0.1.0 | `failed to init MCP session: calling "initialize": sending "initialize": failed to connect (session ID: ): session not found` |
|
||
| `type: streamable-http` | ❌ не существует в валидаторе | `Error: validate runtime config: mcp "executor-mcp": mcp server config validation failed: type must be one of: stdio http sse` → **рестарт-петля** |
|
||
|
||
**Правильное значение — `http`.** Внутренний маппинг (`internal/apps/balda/mcpfx/registry.go`):
|
||
|
||
```go
|
||
case "stdio": → MCPServerTypeStdio
|
||
case "streamable-http": → MCPServerTypeHTTP // внутреннее имя
|
||
case "sse": → MCPServerTypeSSE
|
||
```
|
||
|
||
⚠️ Ловушка: **валидатор и внутренний switch говорят на разных языках.** Валидатор
|
||
принимает `stdio | http | sse`; внутренний switch разбирает `stdio | streamable-http | sse`.
|
||
Пользователь видит только сообщение валидатора → писать надо `http`.
|
||
Не доверяй названию из кода — доверяй тексту ошибки валидатора.
|
||
|
||
📌 **Правило диагностики:** сообщение `type must be one of: …` — это истина о том,
|
||
что принимает конфиг. Читать его буквально, не додумывать «правильный» тип.
|
||
|
||
---
|
||
|
||
## 10f. 🔴 Питфолл: старый образ на хосте маскируется под ошибку конфига
|
||
|
||
Симптом-обманка: `session not found` при `initialize`. Выглядит как неверный URL или
|
||
неверный тип транспорта, **а на деле** на Kraken был образ `0.1.0`, в котором нет
|
||
`uvicorn` → HTTP-режим вообще не стартовал → порт `8890` слушал... ничего.
|
||
|
||
**Как отличить:**
|
||
|
||
```bash
|
||
# 1. Молча падает без логов? Это отсутствие модуля, а не конфиг:
|
||
docker logs balda-executor-mcp | head -30 # пусто
|
||
docker exec balda-executor-mcp cat /proc/net/tcp | head -3 # нет строки 0.0.0.0:22BA (=8890)
|
||
|
||
# 2. Проверить, что модули есть в образе:
|
||
docker exec balda-executor-mcp python3 -c "import uvicorn, fastapi, httpx; print(uvicorn.__version__)"
|
||
```
|
||
|
||
`0x22BA` — это `8890` в hex. Строка `0: 00000000:22BA … 0A` в `/proc/net/tcp` = порт слушается.
|
||
|
||
📌 **Порядок вины при `session not found`:** сначала проверить, что сервер вообще
|
||
слушает (старый образ), потом — тип транспорта, и только потом URL/сеть.
|
||
|
||
---
|
||
|
||
## 10g. 🔴🔴 ГЛАВНЫЙ КОРЕНЬ: MCP-серверы не попадают в сессию из-за снапшота каталога
|
||
|
||
Симптом: **все транспорты верны, оба сервера отвечают 200 из docker-сети, а Балда в чате
|
||
говорит «MCP не вижу, ни одного сервера».** Ошибок в логах нет. `/reset` при этом
|
||
**перечисляет** серверы — то есть ID прочитаны из YAML, но инструменты не подключены.
|
||
|
||
### Диагностика (порядок)
|
||
|
||
```bash
|
||
# 1. Есть ли вообще слова obsidian / executor-mcp в логе Балды?
|
||
docker logs balda-mm 2>&1 | grep -ci "obsidian" # → 0
|
||
docker logs balda-mm 2>&1 | grep -ci "executor-mcp" # → 0
|
||
```
|
||
|
||
Если **0** — проблема не в транспорте и не в сети: каталог не публикует эти источники.
|
||
|
||
```bash
|
||
# 2. Что лежит в снапшотах каталога (state.db)
|
||
STATE=/srv/dev-disk-by-uuid-49e8f586-…/docker/balda/state
|
||
strings "$STATE/state.db" | grep -o 'balda.appruntime_catalog_snapshot:[a-f0-9]*' | head
|
||
strings "$STATE/state.db" | grep -A1 'catalog_snapshot' | grep -o '"kind":"[a-z]*","name":"[a-z-]*"' | sort -u
|
||
```
|
||
|
||
**Показательный результат (баг):**
|
||
|
||
```json
|
||
"sources":[{"id":{"kind":"builtin","name":"balda"}, …}]
|
||
```
|
||
|
||
В снапшоте **только `builtin`**. Источников `configured` (наши `executor-mcp`/`obsidian`)
|
||
нет — значит `PreparePluginCandidate` их не скомпилировала.
|
||
|
||
### Механика (по коду, `internal/apps/balda/`)
|
||
|
||
| Звено | Файл | Что делает |
|
||
|---|---|---|
|
||
| параметры модуля | `catalogapp/module.go` | `params.Norma.MCPServers` ← `runtime.mcp_servers` |
|
||
| сборка | `catalogapp/runtime.go:81` | `configuredMCP: configuredMCPSources(configured)` |
|
||
| компиляция | `runtime.go:128` `PreparePluginCandidate` | `sources := [builtin] + r.configuredMCP + skills + plugins` |
|
||
| публикация | `runtime.go:168` `PublishCandidate` | `persistSnapshot` → `store.PublishApplication` → **`r.mcp.Reconcile(ctx, retained)`** |
|
||
| привязка к сессии | `runtime.go:188` `ResolveEffectiveSnapshot` | если у сессии есть `record.RuntimeSnapshotID` → берётся **именно он** |
|
||
| выдача ID | `module.go:57` `AcquireMCPServerIDs(ctx, snapshot.ID)` | ID MCP берутся из **снапшота**, не из конфига |
|
||
|
||
🔴 **Два независимых условия, оба обязаны выполниться:**
|
||
|
||
1. `PublishCandidate` должен быть **вызван** — он единственный, кто дёргает
|
||
`mcp.Reconcile`. Вызывается при публикации кандидата (плагины/скиллы/старт).
|
||
2. Сессия **не должна** быть закреплена за старым `RuntimeSnapshotID`.
|
||
Привязка вечная: строка `record.RuntimeSnapshotID != ""` → старый снапшот навсегда.
|
||
|
||
📌 **Из этого следует:** правка `runtime.mcp_servers` в YAML **не влияет** на уже
|
||
существующие сессии. Нужно новое состояние каталога + новые сессии.
|
||
|
||
### Правило для будущих правок MCP в Балде
|
||
|
||
1. Залить конфиг.
|
||
2. Убедиться, что `PublishCandidate` отработает (новый каталог) — иначе снапшот остаётся старым.
|
||
3. Создать **новую** сессию (не переиспользовать старую — она прибита к снапшоту).
|
||
4. Проверить **постом от админского токена** (не от бота — свои посты Балда игнорирует):
|
||
|
||
```bash
|
||
docker exec mattermost mmctl --local token generate admin whale-check --json | jq -r '.[0].token'
|
||
```
|
||
|
||
### Открытый вопрос на конец сессии
|
||
|
||
Требует решения Alex (удаление данных → нужно подтверждение):
|
||
- **A)** точечно вычистить ключи `balda.appruntime_catalog_snapshot:*` из `state.db`
|
||
(owner/настройки целы, снапшоты пересоберутся) — рекомендовано;
|
||
- **B)** полный сброс `state.db` (заново `bootstrap-admin` + owner-авторизация).
|
||
|
||
⚠️ `catalog-revisions/` на хосте — `drwx------ root`, обычным пользователем не читается
|
||
(`Permission denied`). Смотреть только через `strings state.db`.
|
||
|
||
---
|
||
|
||
## 11. Что осталось
|
||
|
||
1. ✅ **`runtime.mcp_servers.executor-mcp` → `type: http`** — сделано, залито на Kraken.
|
||
2. ✅ **`obsidian` прописан рядом** (`type: sse` → `http://obsidian-mcp:3101/sse`).
|
||
3. ✅ Образ обновлён до `0.2.0` на Kraken, `tools/list` отдаёт 3 инструмента из docker-сети.
|
||
4. ✅ Балда перезапущена с валидным типом — **стартует без ошибок**, `Balda bot started`,
|
||
`type: http` принят валидатором. Сквозной пост отработан, Балда **ответила**.
|
||
5. 🔴 **Блокер:** Балда отвечает «MCP не вижу». Корень — снапшот каталога (**§10g**):
|
||
в `state.db` снапшот содержит только `builtin`. Нужно решение Alex (вариант A или B, §10g).
|
||
6. 🔴 Сквозная приёмка: `executor_run` из чата → задача на Mac → результат.
|
||
7. 🔴 Логин CLI внутри `balda-executor` — делает **Alex вручную**.
|
||
|
||
### Что проверено живьём (2026-09-26, конец сессии)
|
||
|
||
| Проверка | Результат |
|
||
|---|---|
|
||
| `executor-mcp:8890/mcp` из docker-сети | HTTP 200 + `mcp-session-id` ✅ |
|
||
| `obsidian-mcp:3101/sse` из docker-сети | 200 + `text/event-stream` + `event: endpoint` ✅ |
|
||
| `executor-mcp:8890/sse` (не тот эндпоинт) | 404 — правильно ✅ |
|
||
| `obsidian-mcp:3101/mcp` (не тот эндпоинт) | 400 — правильно ✅ |
|
||
| туннель `agents-bridge.qentra.top/healthz` из `mattermost` | 200 через Cloudflare ✅ |
|
||
| Балда после правки `type: http` | старт без ошибок, ответ в чате ✅ |
|
||
| слова `obsidian`/`executor-mcp` в логе Балды | **0** ❌ → корень в снапшоте (§10g) |
|
||
|
||
📌 **Метод проверки транспорта:** у каждого сервера ровно один рабочий эндпоинт.
|
||
Быстрый тест из `mattermost` (там есть `curl`, в образе Балды — нет):
|
||
|
||
```bash
|
||
docker exec mattermost sh -c 'curl -sS -m 8 -o /dev/null -w "HTTP %{http_code}\n" -H "Accept: text/event-stream" http://obsidian-mcp:3101/sse'
|
||
```
|
||
|
||
## 12. Версии образа
|
||
|
||
| Тег | Что | Замечание |
|
||
|---|---|---|
|
||
| `0.1.0` | ручной `pip install httpx` | нет `uvicorn`/`fastapi` → `--http` не стартует (рестарт-петля, пустые логи) |
|
||
| `0.2.0` | `requirements.txt` (httpx + uvicorn + fastapi) | рабочий HTTP-режим; **залит на Kraken, контейнер пересоздан** |
|
||
|
||
Сборка и доставка (проверенный рецепт):
|
||
|
||
```bash
|
||
# Mac: buildx arm64 + БЕЗ provenance (иначе manifest-list ломает docker load)
|
||
docker buildx build --platform linux/arm64 -t balda-executor-mcp:0.2.0 --provenance=false --load .
|
||
docker save balda-executor-mcp:0.2.0 -o /tmp/balda-executor-mcp-0.2.0.tar
|
||
shasum -a 256 /tmp/balda-executor-mcp-0.2.0.tar # сверить после scp!
|
||
scp /tmp/balda-executor-mcp-0.2.0.tar kraken:/tmp/
|
||
ssh kraken 'docker load -i /tmp/balda-executor-mcp-0.2.0.tar'
|
||
```
|
||
|
||
Образ `0.1.0` на Kraken **не удалять** — оставлен для отката.
|
||
|
||
---
|
||
|
||
## Связанное
|
||
|
||
- [[personal/plans/balda-mm-phase4-agents]] — план Фазы 4 (шаг 5)
|
||
- [[personal/tech/agents-bridge]] — мост :8790, к которому ходит этот MCP
|
||
- [[personal/projects/balda/balda-kraken-mm-deploy]] — деплой Балды на Kraken (конфиг, `.env`, сети)
|
||
- [[personal/tech/macos-docker-virtiofs-mount-limits]] — ограничения macOS Docker
|
||
- `~/Developer/balda-executor-mcp` — код
|
||
- скилл `native-mcp` — handshake streamable-http, питфоллы
|