30 KiB
Executable File
[!WARNING] Историческая схема Balda Не использовать пути, контейнеры, provider или MCP transport из этой заметки для текущего запуска. Канонический текущий runbook: personal/projects/balda/setup. Актуально:
balda-mm-mac,/Users/admin/Docker/balda-mm-mac/config/config.yaml, Mattermost, Codex ACP, Obsidiantype: http→http://host.docker.internal:3101/mcp.
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-инструменты не попадают в сессию на Mattermost. Корень найден — §10h (секция провайдера +session_memoryломает/reset). Ждёт решения Alex (A/B). ⚠️ Прежняя версия заметки указывала на снапшот каталога — диагноз был неверным, §10g. Связка: Балда (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-контейнер. Это не работает по двум причинам:
docker.sockв контейнер Балды не смонтирован. Проверено:docker inspect balda-mmдаёт только три тома (config/state/workspace) — сокета нет. Значитdocker runизнутри Балды невозможен.- В образе Балды нет 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, но инструменты не подключены.
⚠️ Важно. В первой редакции этой заметки корнем был объявлен снапшот каталога («в
state.dbтолькоbuiltin→ чистить снапшоты»). Это оказался неверный диагноз. Проверка показала, что снапшот в порядке и оба источника в нём есть. Удалять данные НЕ нужно. Настоящий корень — §10h ниже.
Шаг 1. Подтвердить, что дело не в транспорте
docker logs balda-mm 2>&1 | grep -ci "obsidian" # → 0
docker logs balda-mm 2>&1 | grep -ci "executor-mcp" # → 0
Шаг 2. Прочитать снапшот каталога (ожидаемо — он ЗДОРОВ)
⚠️ Только sqlite3 с хоста Kraken (в контейнере Балды нет ни shell, ни sqlite3).
strings для разбора структуры НЕ годится.
STATE=/srv/dev-disk-by-uuid-49e8f586-…/docker/balda/state
DB="$STATE/state.db"
sqlite3 "$DB" "SELECT key, substr(updated_at,1,25) FROM balda_app_kv
WHERE key LIKE '%catalog_snapshot%' ORDER BY updated_at DESC LIMIT 1;"
sqlite3 "$DB" "SELECT value_json FROM balda_app_kv WHERE key LIKE '%<hash>%';" | jq '.sources, .mcp_servers'
Наблюдаемый (исправный) результат:
"sources":[{"kind":"builtin","name":"balda"},
{"kind":"configured-mcp","name":"executor-mcp"},
{"kind":"configured-mcp","name":"obsidian"}]
"mcp_servers":[{"name":"executor-mcp","transport":"streamable-http"},
{"name":"obsidian","transport":"sse"}]
Если источники и транспорты видны — каталог исправен, идти в §10h.
Схема таблиц (пригодится)
| Что | Где |
|---|---|
| KV-хранилище снапшотов | balda_app_kv (namespace='balda.app', key='runtime_catalog_snapshot:<hash>', значение в value_json) |
| Сессии | balda_runtime_sessions (session_id, state_json, updated_at) — поля snapshot в state_json нет |
| Ревизии каталога на диске | state/catalog-revisions/ — drwx------ root, обычным пользователем не читается (Permission denied) |
Механика (по коду, 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 берутся из снапшота, не из конфига |
🔴 Два независимых условия, оба обязаны выполниться:
PublishCandidateдолжен быть вызван — он единственный, кто дёргаетmcp.Reconcile. Вызывается при публикации кандидата (плагины/скиллы/старт).- Сессия не должна быть закреплена за старым
RuntimeSnapshotID. Привязка вечная: строкаrecord.RuntimeSnapshotID != ""→ старый снапшот навсегда.
📌 Из этого следует: правка runtime.mcp_servers в YAML не влияет на уже
существующие сессии. Нужно новое состояние каталога + новые сессии.
Правило для будущих правок MCP в Балде
- Залить конфиг.
- Прописать серверы в секции ПРОВАЙДЕРА — см. §10h, это обязательное условие.
- Создать новую сессию.
- Проверить постом от админского токена (не от бота — свои посты Балда игнорирует):
docker exec mattermost mmctl --local token generate admin whale-check --json | jq -r '.[0].token'
10h. 🔴🔴 НАСТОЯЩИЙ КОРЕНЬ (найден 2026-09-26): две причины, обе исправлены
Причина 1 — провайдер не ссылался на MCP-серверы
Официальная дока (docs/reference/configuration.md):
MCP servers are configured in
runtime.mcp_serversand referenced by providers viaruntime.providers.<id>.mcp_servers.
Формула из той же доки (стр. 555):
effective MCP IDs = bundled defaults + runtime.providers.<provider_id>.mcp_servers + balda.mcp_servers
Я добавлял ID только в balda.mcp_servers — этого мало: секция провайдера подключает
серверы к рантайму, а balda.mcp_servers лишь дополняет список в сессии.
Исправлено:
runtime:
providers:
deepseek:
type: openai
openai:
base_url: "https://api.deepseek.com/v1"
api_key: "${DEEPSEEK_API_KEY}"
model: "deepseek-chat"
mcp_servers: # 🔴 ОБЯЗАТЕЛЬНАЯ секция
- executor-mcp
- obsidian
Причина 2 — 🔴 session_memory включает MCP-биндер, но на Mattermost ломает /reset
Включатель MCP-инструментов в сессии — это balda.session_memory.enabled, НЕ balda.memory.
Две разные секции, легко перепутать.
Механика (internal/apps/balda/sessionapp/module.go:191-196):
fx.Annotate(
func(broker *sessionmemorymcp.ContextBroker, enabled bool) baldaagent.SessionMCPBinder {
return SessionMCPBinderAdapter{Broker: broker, Enabled: enabled}
},
fx.ParamTags(``, `name:"balda_session_memory_enabled"`),
)
enabled = cfg.Balda.SessionMemory.Enabled. Далее в agent/runtime_manager.go:308-311:
bindingEnabled := binder != nil && registry != nil
if status, ok := binder.(sessionMCPBinderStatus); ok && !status.SessionMCPEnabled() {
bindingEnabled = false // ← MCP-инструменты отключаются целиком
}
НО при session_memory.enabled: true сброс сессии падает:
{"level":"warn","component":"balda.session_manager",
"error":"observe reset boundary: no session-memory classifier is registered for \"mattermost\"",
"boundary_reason":"reset"}
В чате: Could not reset this session.
Причина: ScopeResolver (preflight.go) регистрирует классификаторы только для
telegram, slackagent, zulip:
sessionmemoryapp.NewScopeResolver(map[string]sessionmemoryapp.ScopeClassifier{
baldatelegram.ChannelType: baldatelegram.ClassifyLocatorScope,
baldaslackagent.ChannelType: baldaslackagent.ClassifyLocatorScope,
baldazulip.ChannelType: baldazulip.ClassifyLocatorScope,
})
Mattermost в этом списке НЕТ.
Итоговый вывод (замкнутый круг)
session_memory |
MCP-инструменты | /reset на Mattermost |
|---|---|---|
false (текущее) |
❌ не подключаются | ✅ работает |
true |
✅ подключились бы | ❌ «Could not reset this session.» |
MCP-инструменты в сессии Балды на Mattermost сейчас НЕДОСТИЖИМЫ — ограничение апстрима,
не ошибка конфигурации. session_memory оставлен выключенным (иначе ломается /reset).
Что осталось для разблокировки (нужно решение Alex)
- A) Добавить
ClassifyLocatorScopeдля Mattermost вScopeResolver(патч апстрима, ~20 строк по образцу zulip) → MCP заработает в Mattermost. Рекомендовано — вся работа Alex идёт в Mattermost. - B) Перевести бота на канал, где классификатор есть (
telegram/slackagent/zulip) → MCP заработает без правок кода.
⛔ НЕ делать: чистить state.db / ключи снапшотов. Каталог исправен (§10g),
данные трогать не нужно.
Питфоллы тестирования (сожрали время)
| Питфолл | Симптом | Как правильно |
|---|---|---|
| Пост отправлен от имени бота | dropping posted event: bot not mentioned — Балда молчит |
слать токеном админа: mmctl --local token generate admin … |
Забыт @balda в тексте |
то же bot not mentioned |
всегда упоминать бота |
Правка .env, а не plist/конфига |
«перезапустил, но старое значение» | смотреть, откуда реально читается |
docker logs сразу после up |
логи пусты (буферизация) | подождать ~60-80 с (OnStart ~47 с) |
Ожидание, что /reset пересоздаст сессию при выключенном session_memory |
«серверы в списке, а инструментов нет» | серверы в списке ≠ инструменты подключены |
11. Что осталось
- ✅
runtime.mcp_servers.executor-mcp→type: http— сделано, залито на Kraken. - ✅
obsidianпрописан рядом (type: sse→http://obsidian-mcp:3101/sse). - ✅ Образ обновлён до
0.2.0на Kraken,tools/listотдаёт 3 инструмента из docker-сети. - ✅ Балда перезапущена с валидным типом — стартует без ошибок,
Balda bot started,type: httpпринят валидатором. Сквозной пост отработан, Балда ответила. - 🔴 Блокер: Балда отвечает «MCP не вижу». Корень найден (§10h): провайдер не
ссылался на серверы (исправлено) +
session_memory.enabledломает/resetна Mattermost (no session-memory classifier for "mattermost") → включатель MCP держать выключенным. Нужно решение Alex по разблокировке (вариант A или B, §10h). ⛔state.dbНЕ чистить — каталог исправен (§10g). - 🔴 Сквозная приёмка:
executor_runиз чата → задача на Mac → результат. - 🔴 Логин 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 не удалять — оставлен для отката.
Связанное
- 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, питфоллы