Files
obsidian-vault/personal/tech/balda-executor-mcp.md
T

23 KiB
Executable File
Raw Blame History

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. Два режима запуска

# 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-транспортом. Конфиг Балды тогда:

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)

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:

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):

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 слушал... ничего.

Как отличить:

# 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, но инструменты не подключены.

Диагностика (порядок)

# 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 — проблема не в транспорте и не в сети: каталог не публикует эти источники.

# 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

Показательный результат (баг):

"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. Проверить постом от админского токена (не от бота — свои посты Балда игнорирует):
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, в образе Балды — нет):

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, контейнер пересоздан

Сборка и доставка (проверенный рецепт):

# 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 не удалять — оставлен для отката.


Связанное