Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
ci / test (push) Successful in 2m22s
ci / lint (push) Successful in 4m31s

This commit is contained in:
Maksim Totmin
2026-10-07 20:13:23 +07:00
commit fdbd4a4fb6
54 changed files with 5672 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Секреты модуля omnichannel-mcp. Реальные значения — в .env (gitignored)
# или в config.local.json рядом с config.json.
#
# Основная учётная запись (право записи):
OMNI_USER=admin
OMNI_PASSWORD=
# Необязательная учётная запись только для чтения (least-privilege):
# OMNI_RO_USER=
# OMNI_RO_PASSWORD=
# Необязательный GitLab-токен для download_release:
# OMNI_GITLAB_TOKEN=
# Уровень логов модуля (debug|info|warn|error):
OMNI_LOG_LEVEL=info
+53
View File
@@ -0,0 +1,53 @@
name: ci
# Гейт качества на PR и push в main: vet, тесты с race-детектором, линт,
# gofmt и проверка tidy. Внутренний CI (Gitea Actions).
#
# ВАЖНО: job-контейнер golang:1.27 не содержит Node.js, поэтому
# actions/checkout не работает — checkout делается вручную через git.
# Приватный модуль forge-toolkit лежит на том же Gitea, доступ — по токену.
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: backend
container: golang:1.27
steps:
- name: checkout
run: |
git clone "https://x-access-token:${{ github.token }}@git.totmin.ru/${{ github.repository }}.git" .
git checkout -q "${{ github.sha }}"
- name: configure private modules
run: |
git config --global url."https://x-access-token:${{ github.token }}@git.totmin.ru/".insteadOf "https://git.totmin.ru/"
go env -w GOPRIVATE=git.totmin.ru
- name: vet
run: go vet ./...
- name: test (race)
run: go test -race ./... -count=1
lint:
runs-on: backend
container: golang:1.27
steps:
- name: checkout
run: |
git clone "https://x-access-token:${{ github.token }}@git.totmin.ru/${{ github.repository }}.git" .
git checkout -q "${{ github.sha }}"
- name: configure private modules
run: |
git config --global url."https://x-access-token:${{ github.token }}@git.totmin.ru/".insteadOf "https://git.totmin.ru/"
go env -w GOPRIVATE=git.totmin.ru
- name: install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2
- name: lint
run: $(go env GOPATH)/bin/golangci-lint run ./...
- name: gofmt
run: |
unformatted=$(gofmt -l .)
[ -z "$unformatted" ] || { echo "gofmt: требуется форматирование: $unformatted"; exit 1; }
- name: go mod tidy check
run: go mod tidy -diff
+8
View File
@@ -0,0 +1,8 @@
# Секреты и локальные данные — не коммитим.
config.local.json
*.local.json
.env
# Бинарник модуля и кэш Python.
/omnichannel-mcp
__pycache__/
+23
View File
@@ -0,0 +1,23 @@
# Changelog
Все заметные изменения проекта описываются здесь.
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
версионирование — [Semantic Versioning](https://semver.org/lang/ru/).
## [Unreleased]
## [0.1.0] — 2026-10-07
### Added
- MCP-сервер `omnichannel-mcp` для управления платформой Omnichannel через API
`config_server` 1.1.0+ (36 инструментов: наблюдение, конфигурация, жизненный
цикл, релиз, обслуживание).
- Режим «только чтение» и отдельная read-only учётная запись.
- Подтверждение разрушительных операций (`confirm`).
- Мультисерверная конфигурация, `${VAR}` и `config.local.json` для секретов.
- Capability-probe (`server_info`) с определением версии API.
- Асинхронная работа с задачами (`get_task`/`wait`) с ограничением по времени.
- Файловый jail для обновления фронта (`front_roots`).
- Документация (`docs/`), примеры конфигураций и промптов, воспроизводимый
демо-стенд.
+47
View File
@@ -0,0 +1,47 @@
# Разработка
Внутренний проект. Ниже — короткие правила, чтобы изменения проходили ревью
быстро.
## Окружение
```bash
export GOPRIVATE=git.totmin.ru
make build # собрать бинарник
make ci # build + test(race) + lint + tidy-check (то же, что CI)
```
Требуется Go 1.27+ и доступ к приватному модулю `forge-toolkit` на
`git.totmin.ru` (для него задаётся `GOPRIVATE`).
## Локальный стенд
```bash
make demo # поднять config_server v1.1.0 и прогнать сквозной сценарий
make demo-down # остановить и очистить
```
## Принципы кода
- Домен (`internal/configserver`) не зависит от MCP; MCP-слой
(`internal/tools`) — только трансляция запрос/ответ.
- Простота и читаемость важнее «умности»; код документируется по существу.
- Новый инструмент: схема с описанием, обработчик, тест, запись в
`docs/tools.md`.
- Секреты только из переменных окружения или `config.local.json`
(в `.gitignore`), никогда — в `config.json` и коде.
## Перед коммитом
```bash
make ci
```
Проверки: `gofmt`, `go vet`, `go test -race`, `golangci-lint`, `go mod tidy`.
CI (`.gitea/workflows/ci.yml`) запускается на PR и push в `main`.
## Коммиты и PR
- Осмысленные сообщения; в PR — что, зачем, как проверяли, влияние на
безопасность.
- Пользовательские изменения отмечайте в `CHANGELOG.md`.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+49
View File
@@ -0,0 +1,49 @@
# omnichannel-mcp — сборка, тесты, линт.
GO ?= go
GOLANGCI_LINT ?= golangci-lint
.PHONY: build test fmt lint tidy tidy-check ci demo e2e demo-down
# Бинарник модуля.
build:
$(GO) build -o omnichannel-mcp .
# Тесты с race-детектором (основной гейт).
test:
$(GO) test -race ./... -count=1
# Форматирование.
fmt:
gofmt -w .
# Статический анализ + проверка форматирования.
lint:
$(GOLANGCI_LINT) run ./...
@unformatted=$$(gofmt -l .); \
if [ -n "$$unformatted" ]; then echo "gofmt: нужно форматирование: $$unformatted"; exit 1; fi
tidy:
$(GO) mod tidy
# Проверка, что go.mod/go.sum в tidy-состоянии.
tidy-check:
@$(GO) mod tidy -diff >/dev/null 2>&1 || { echo "go.mod не tidy — запусти make tidy"; $(GO) mod tidy -diff; exit 1; }
@echo "tidy: ok"
# Локальный гейт (то же, что CI).
ci: build test lint tidy-check
# Демо: поднять локальный стенд и прогнать сквозной сценарий.
demo:
docker compose -f examples/demo/docker-compose.yml up -d
$(GO) build -o omnichannel-mcp .
python3 examples/demo/demo.py
# E2E: то же плюс реальный config-agent, исполняющий задачи деплоя.
e2e:
docker compose -f examples/demo/docker-compose.yml up -d
$(GO) build -o omnichannel-mcp .
python3 examples/demo/e2e.py
demo-down:
docker compose -f examples/demo/docker-compose.yml down -v
+126
View File
@@ -0,0 +1,126 @@
# omnichannel-mcp
MCP-сервер для управления платформой **Omnichannel** через API
[`config_server`](../API.md): развёртывание релизов и эксплуатация — из любого
AI-ассистента, без веб-интерфейса.
Сервер реализует [Model Context Protocol](https://modelcontextprotocol.io) и
работает как обычный stdio-процесс: Claude Desktop, Cursor или любой другой
MCP-клиент запускает его локально, а он транслирует запросы ассистента в HTTP API
`config_server`. Состояния не хранит.
## Возможности
- **Развёртывание** — схема и манифест стенда, выгрузка релиза (пакеты и
образы), запуск сервисов на одном или нескольких серверах, обновление фронта.
- **Эксплуатация** — сводка по стенду, статусы сервисов и хостов, аудит задач
агентов, журналы миграций, диагностика сопоставления IP.
- **Конфигурация** — чтение и изменение compose/env, версии и безопасный откат,
повторная выдача конфига агенту.
- **Безопасность** — режим «только чтение», отдельные read-only креды,
подтверждение разрушительных операций, секреты только из окружения.
## Требования
- Запущенный **`config_server` 1.1.0+**, доступный по сети (`http://host:5005`).
- Учётная запись `config_server`.
- **Go 1.27+** для сборки.
- MCP-клиент, поддерживающий stdio-серверы.
## Быстрый старт
```bash
export GOPRIVATE=git.totmin.ru
go build -o omnichannel-mcp .
./omnichannel-mcp --health # -> ok
cp examples/config.single-server.json config.json # укажите base_url
export OMNI_USER=admin OMNI_PASSWORD='...'
./omnichannel-mcp --check-config -config config.json
```
Подключение к клиенту (общий вид):
```json
{
"mcpServers": {
"omnichannel-mcp": {
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
"env": { "OMNI_USER": "admin", "OMNI_PASSWORD": "СЕКРЕТ" }
}
}
}
```
Проверка: попросите ассистента «покажи, что сейчас на стенде» — он вызовет
`server_info` и `overview`.
## Пример: деплой за несколько шагов
**Один сервис** (уже зарегистрирован в `config_server`):
> «Обнови compose сервиса `demo_web` на образ `nginx:1.27`, подними и дождись
> результата.»
Ассистент: `set_compose` → `deploy` → `get_task` → `overview`.
**Релиз на несколько серверов:**
> «Сохрани schema.json и manifest.json, разложи хосты, скачай релиз с образами,
> дождись завершения и запусти сервисы.»
Ассистент: `save_deployment_files` → `ip_match` → `seed_hosts` →
`download_release` → `release_job` → `start_services` → `deployment_tasks` →
`overview`.
Подробные playbooks (1 сервер, N серверов, фронт, откат) с форматами
`schema.json`/`manifest.json` — [docs/deployment.md](docs/deployment.md).
Готовые формулировки промптов — [examples/prompts.md](examples/prompts.md).
## Демо без прода
```bash
docker compose -f examples/demo/docker-compose.yml up -d # локальный config_server v1.1.0
go build -o omnichannel-mcp .
python3 examples/demo/demo.py # сквозной сценарий через MCP
python3 examples/demo/e2e.py # + реальный config-agent: deploy/restart/down
docker compose -f examples/demo/docker-compose.yml down -v
```
Подробнее — [examples/demo/README.md](examples/demo/README.md).
## Документация
- [docs/getting-started.md](docs/getting-started.md) — установка и первое подключение
- [docs/configuration.md](docs/configuration.md) — полный справочник конфигурации
- [docs/tools.md](docs/tools.md) — все 36 инструментов
- [docs/deployment.md](docs/deployment.md) — развёртывание: 1 сервер, N серверов, фронт, откат
- [docs/operations.md](docs/operations.md) — эксплуатация и диагностика
- [docs/security.md](docs/security.md) — модель безопасности
- [docs/architecture.md](docs/architecture.md) — как устроено внутри
- [docs/api-compatibility.md](docs/api-compatibility.md) — версии API
- [docs/mcp-clients.md](docs/mcp-clients.md) — подключение к клиентам
## Безопасность (кратко)
- `read_only: true` по умолчанию; мутации требуют явного `read_only=false`.
- Разрушительные операции требуют `confirm="true"`.
- Read-инструменты могут ходить под отдельными read-only кредами.
- Секреты — только из переменных окружения или `config.local.json` (не в git).
- Подробно — [docs/security.md](docs/security.md).
## Разработка
```bash
export GOPRIVATE=git.totmin.ru
make build # собрать бинарник
make ci # build + test(race) + lint + tidy-check
make demo # поднять локальный стенд и прогнать сквозной сценарий
```
Подробнее — [CONTRIBUTING.md](CONTRIBUTING.md).
## Лицензия
Apache-2.0 — см. [LICENSE](LICENSE). Внутренний проект компании.
+14
View File
@@ -0,0 +1,14 @@
{
"servers": [
{
"alias": "prod",
"base_url": "http://config-server.example:5005",
"username": "${OMNI_USER}",
"password": "${OMNI_PASSWORD}",
"front_roots": []
}
],
"default": "prod",
"read_only": true,
"allow_hosts": []
}
+47
View File
@@ -0,0 +1,47 @@
# Совместимость с версиями API
`omnichannel-mcp` рассчитан на API **`config_server` 1.1.0** (он же `latest`).
Эта версия добавила JSON-эндпоинты, на которые опирается сервер.
## Что появилось в 1.1.0
| Эндпоинт | Назначение |
|---|---|
| `POST /api/login`, `POST /api/logout` | JSON-авторизация для сторонних клиентов |
| `GET /api/whoami` | Проверка сессии |
| `GET /api/application/<id>` | Детализация сервиса в JSON |
| `POST /api/compose/<id>` | Установка compose через JSON |
| `POST /api/env/<id>/<file>` | Установка env через JSON |
| `GET /api/tasks`, `GET /api/task/<id>` | Список/статус задач |
В более старых версиях (≤ 1.0.20) этих эндпоинтов нет: логин был только
HTML-формой, конфиг правился HTML-страницами, а список задач — только по хосту.
`update_front` появился раньше (1.0.19+).
## Capability-probe
Инструмент `server_info` определяет версию автоматически (по доступности
`GET /api/whoami`) и возвращает:
```json
{
"server": "prod",
"base_url": "http://10.20.30.40:5005",
"read_only": false,
"capabilities": {
"version": "1.1.0",
"features": { "api_login": true, "set_compose": true, "tasks_filter": true, … }
}
}
```
На старом стенде `version` будет `legacy (<1.1.0)`, а `features` — пустым. В этом
случае инструменты, требующие новых эндпоинтов, вернут понятное сообщение вместо
«тихой» поломки. Наблюдение и часть операций на старых версиях недоступны —
обновите `config_server`.
## Рекомендация
Перед автоматизацией вызовите `server_info` и убедитесь, что `version` = `1.1.0`.
Если планируется смешанный парк — запускайте отдельный инстанс модуля на каждый
контур со своим `server`-конфигом.
+74
View File
@@ -0,0 +1,74 @@
# Архитектура
## Место в системе
```
┌────────────────┐ MCP (stdio, JSON-RPC) ┌──────────────────┐
│ MCP-клиент │ ◄──────────────────────► │ omnichannel-mcp │
│ (AI-ассистент)│ │ (этот сервер) │
└────────────────┘ └────────┬─────────┘
│ HTTP (session)
▼
┌──────────────────┐
│ config_server │
│ (Flask API/UI) │
└────────┬─────────┘
│ задачи агентам
▼
┌───────────────────────────┐
│ агенты на хостах │
│ docker-compose / swarm │
└───────────────────────────┘
```
`omnichannel-mcp` — тонкий клиент. Он не выполняет команды сам: создаёт задачи
через API `config_server`, а те забирают агенты нод. Сервер без состояния — при
перезапуске ничего не теряется.
## Модель задач
Действия жизненного цикла (`deploy`, `restart`, `down`, `migrate`,
`update_front`, релизные операции) асинхронны:
1. Инструмент вызывает API → создаётся задача → возвращается `task_id`.
2. Агент ноды забирает задачу, выполняет `docker-compose`, публикует результат.
3. Клиент опрашивает `get_task` до `completed`/`failed`.
Поэтому у инструментов есть параметр `wait`: по умолчанию он выключен (loop
ассистента последовательный — не стоит его блокировать), а при `wait=true`
ожидание ограничено `task_poll_max_sec` и не считается ошибкой, если время вышло:
вернётся текущее состояние задачи.
## Слои модуля
```
main.go каркас lifecycle: --health, -config, stdio
internal/configserver/ домен: без зависимостей от MCP
config.go разбор/валидация конфига, ${VAR}, merge local
session.go HTTP-клиент: cookie-сессия, релогin, ретраи GET
manager.go потокобезопасный диспетчер: конфиг, сессии, семафоры
api_*.go типизированные вызовы эндпоинтов config_server
internal/tools/ MCP-слой: разбор аргументов → вызов домена → текст
registry.go toolkit.go patterns.go util.go
observe.go configure.go operate.go release.go maintain.go overview.go
```
Правило разделения: домен тестируется без MCP; MCP-слой не содержит бизнес-логики.
## Надёжность
- **Сессия:** Flask-cookie хранится в cookie-jar; login ленивый, при `401`
выполняется один повторный вход и один повтор запроса.
- **Повторы:** только идемпотентные GET (сеть/5xx). Мутации не повторяются.
- **Ограничения:** context-deadline на каждый вызов, лимит на размер ответа,
обрезка вывода, семафор мутаций на сервер.
- **Ошибки:** доменные (4xx/5xx с текстом, ошибки валидации) возвращаются как
recoverable — ассистент видит причину и может исправить; инфраструктурные
(сеть) — как ошибка протокола.
- **Логи:** только в stderr (stdout занят JSON-RPC), с маскированием секретов.
## Логи и наблюдаемость
Логи модуля пишутся в stderr; уровень задаётся `--log-level` или `OMNI_LOG_LEVEL`
(`debug|info|warn|error`). HTTP-сервер `config_server` — источник данных о
задачах и статусах.
+153
View File
@@ -0,0 +1,153 @@
# Конфигурация
Конфиг — JSON-файл (по умолчанию `config.json`), который задаёт подключения к
`config_server` и политику безопасности. Рядом можно положить
`config.local.json` с секретами — он накладывается поверх и не коммитится.
## Как задать путь к конфигу
Приоритет (первый найденный):
1. аргумент инструмента `config_path` (для обёрток; обычно не нужен);
2. флаг запуска `-config /path/config.json`;
3. каталог из переменной окружения `OMNI_CONFIG_DIR` (в нём берётся
`omnichannel-mcp.json`).
Обычный режим — флаг `-config`.
## Структура
```json
{
"servers": [ { … }, { … } ],
"default": "prod",
"read_only": true,
"allow_hosts": ["10.20.30.40"]
}
```
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `servers` | массив | — | Подключения к стендам. Обязательно, если не задан shorthand. |
| `default` | строка | первый сервер | Алиас сервера, если в вызове не указан `server`. |
| `read_only` | bool | `true` | Запрещает изменяющие инструменты (безопасный режим). |
| `allow_hosts` | массив | пусто | Если задан — `base_url` любого сервера обязан быть на этих хостах. |
### Поля сервера
```json
{
"alias": "prod",
"base_url": "http://10.20.30.40:5005",
"username": "${OMNI_USER}",
"password": "${OMNI_PASSWORD}",
"readonly_username": "${OMNI_RO_USER}",
"readonly_password": "${OMNI_RO_PASSWORD}",
"gitlab_token": "${OMNI_GITLAB_TOKEN}",
"insecure_skip_verify": false,
"front_roots": ["/srv/omni-front-builds"],
"timeout_sec": 30,
"upload_timeout_sec": 300,
"task_poll_max_sec": 120,
"task_poll_interval_sec": 2,
"max_output_bytes": 100000,
"max_upload_bytes": 536870912,
"max_concurrent_mutations": 1
}
```
| Поле | По умолчанию | Назначение |
|---|---|---|
| `alias` | — | Имя стенда для выбора сервером (`server:"prod"`). |
| `base_url` | — | Адрес API `config_server` (http/https). Обязательно. |
| `username` / `password` | — | Учётная запись с правом записи. |
| `readonly_username` / `readonly_password` | — | Необязательная учётная запись только для чтения (read-инструменты используют её). |
| `gitlab_token` | — | Токен для `download_release` (если не задан — используется значение сервера/окружения). |
| `insecure_skip_verify` | `false` | Отключить проверку TLS-сертификата (самоподписанные стенды). |
| `front_roots` | `[]` | Разрешённые каталоги локальных сборок фронта (jail). Пусто — `update_front` запрещён. |
| `timeout_sec` | `30` | Таймаут обычных запросов. |
| `upload_timeout_sec` | `300` | Таймаут загрузки фронта и синхронной выгрузки релиза. |
| `task_poll_max_sec` | `120` | Максимум ожидания задачи при `wait=true`. |
| `task_poll_interval_sec` | `2` | Интервал опроса задачи. |
| `max_output_bytes` | `100000` | Лимит длины вывода задачи (обрезается, хвост сохраняется). |
| `max_upload_bytes` | `536870912` | Лимит суммарного размера сборки фронта (512 МиБ). |
| `max_concurrent_mutations` | `1` | Максимум одновременных изменяющих операций на сервер. |
## Секреты
Два способа, оба безопасны (не попадают в репозиторий):
**1. Переменные окружения** — подстановка `${VAR}` в `config.json`:
```json
{ "password": "${OMNI_PASSWORD}" }
```
Переменная должна быть задана в окружении процесса MCP-сервера. Если её нет —
сервер откажется стартовать и явно укажет имя переменной (fail-closed).
**2. `config.local.json`** — файл рядом с `config.json` (в `.gitignore`),
накладывается поверх: объекты сливаются, серверы — по `alias`.
```json
{
"servers": [
{ "alias": "prod", "password": "реальный-пароль" }
],
"read_only": false
}
```
## Shorthand для одного сервера
Вместо массива `servers` можно задать корневые поля — это один сервер с
алиасом `default`:
```json
{
"base_url": "http://10.20.30.40:5005",
"username": "${OMNI_USER}",
"password": "${OMNI_PASSWORD}"
}
```
## Несколько стендов
Каждому стенду — свой `alias`; инструменты принимают аргумент `server`:
```json
{
"servers": [
{ "alias": "prod", "base_url": "http://10.20.30.40:5005", "username": "${P_USER}", "password": "${P_PASS}" },
{ "alias": "stage", "base_url": "http://10.20.31.40:5005", "username": "${S_USER}", "password": "${S_PASS}" }
],
"default": "prod",
"allow_hosts": ["10.20.30.40", "10.20.31.40"]
}
```
Пример целиком — [../examples/config.multi-server.json](../examples/config.multi-server.json).
## Режим «только чтение» (наблюдатель)
Для мониторинга удобно поднять отдельный инстанс без права изменений:
```json
{
"servers": [{ "alias": "prod", "base_url": "http://10.20.30.40:5005",
"readonly_username": "${OMNI_RO_USER}",
"readonly_password": "${OMNI_RO_PASSWORD}" }],
"read_only": true
}
```
Пример — [../examples/config.readonly-observer.json](../examples/config.readonly-observer.json).
## Проверка конфига
```bash
./omnichannel-mcp --check-config -config config.json
```
Печатает итоговый (слитый) конфиг с замаскированными секретами, список серверов
и `default`. Код возврата ≠ 0 при ошибке — удобно для CI.
+132
View File
@@ -0,0 +1,132 @@
# Развёртывание
Два пути: **быстрый** (для уже зарегистрированного сервиса — изменить compose/env
и поднять) и **полный релиз** (схема + манифест → выгрузка релиза → запуск на
одном или нескольких серверах).
> Перед изменяющими операциями убедитесь, что `read_only=false` в конфиге, а
> ассистент запрашивает подтверждение (`confirm`) на разрушительные шаги.
## Быстрый путь: один уже известный сервис
```text
Промпт: «Обнови compose сервиса demo_web на образ nginx:1.27, подними его и дождись результата.»
```
Вызовы:
1. `list_applications` — найти `demo_web` и его `app_id` (или `get_application`, если id известен).
2. `set_compose {app_id, content}` — записать новый compose (создаётся версия, конфиг уходит агенту).
При необходимости — `set_env {app_id, filename, content}`.
3. `deploy {app_id, wait:true}` — `docker-compose up -d` и ожидание задачи.
4. `overview` — убедиться, что сервис поднялся.
Для перезапуска — `restart`; для остановки — `down {confirm:true}`.
## Полный релиз: один сервер
Минимальная схема — один хост:
```json
{
"vm1": { "ip": "10.20.30.11", "services": ["user_system_front"] }
}
```
```text
Промпт: «Сохрани schema.json и manifest.json, разложи хост, скачай релиз с образами,
дождись завершения и запусти сервисы.»
```
1. `save_deployment_files {schema, manifest}`
2. `ip_match` — проверить, что IP хоста сопоставлен агенту.
3. `seed_hosts` — создать запись хоста до выхода агента.
4. `download_release {load_images:true, confirm:true}` → `{"status":"started"}`
5. `release_job` (опрос) — `status: running` → `completed`
6. `start_services` → `start_task_ids`
7. `deployment_tasks` / `get_task {task_id, wait:true}` — результат запуска
8. `overview` — сводка
## Полный релиз: несколько серверов
Схема описывает все VM и их сервисы:
```json
{
"vm-node-1": { "ip": "10.20.30.11", "services": ["chats", "user_system"] },
"vm-node-2": { "ip": "10.20.30.12", "services": ["report_system"] }
}
```
Манифест сопоставляет сервис и пакет релиза (URL архива в GitLab):
```json
{
"services": {
"chats": { "package": "https://gitlab.example/api/v4/.../chats.tar.gz" },
"user_system": { "package": "https://gitlab.example/api/v4/.../user_system.tar.gz" },
"report_system": { "package": "https://gitlab.example/api/v4/.../report_system.tar.gz" }
}
}
```
Порядок:
1. `save_deployment_files {schema, manifest}` (при необходимости — `prebuild_vars`).
2. `ip_match` — увидеть `matched_pairs` и `unmatched_schema_ips`.
Если IP в схеме не совпадают с IP агентов — задать соответствие через
`save_deployment_files {ip_overrides}`:
```json
{ "10.20.30.11": "10.20.30.101", "10.20.30.12": "10.20.30.102" }
```
затем снова `ip_match`.
3. `fill_vars` (опционально) — заполнить `prebuild_vars` из схемы.
4. `seed_hosts` — создать записи хостов.
5. `download_release {load_images:true, confirm:true}` — запускает фоновую
выгрузку пакетов и образов; прогресс — `release_job`
(`phase`, `images_pulled`/`images_total`).
Без образов: `download_release {load_images:false}` вернёт
`downloaded_services` и `sync_task_ids`.
6. `start_services` → `start_task_ids` (по одной задаче на хост).
7. `deployment_tasks` и `get_task {wait:true}` — дождаться результата по каждому хосту.
8. `overview` — итоговая сводка; `list_applications` — статусы сервисов.
> Токен GitLab: задайте `gitlab_token` в конфиге сервера (или переменной
> окружения) — тогда не придётся передавать секрет в аргументе.
## Обновление фронта
Фронт-сервисы (`user_system_front`, `chat_widget`, `scenario_front` и др.)
принимают собранную папку.
1. В конфиге сервера задайте `front_roots` — каталог, куда вы кладёте сборки:
```json
"front_roots": ["/srv/omni-front-builds"]
```
2. ```text
Промпт: «Обнови фронт user_system_front из /srv/omni-front-builds/us.»
```
`update_front {app_id, build_dir:"/srv/omni-front-builds/us", confirm:true}` →
задача агенту → `get_task {wait:true}`.
Каталог обязан лежать внутри `front_roots` (защита от загрузки произвольных
путей); содержимое `build_folder` на хосте заменяется целиком.
## Откат конфигурации
Каждое изменение compose/env создаёт версию, поэтому откат безопасен.
- Compose: `get_application {app_id}` → выбрать `version_id` → `restore_compose {version_id, confirm:true}`.
- Env: `env_versions {app_id, filename}` → `restore_env_version {version_id, confirm:true}`.
Восстановление создаёт **новую** версию (история сохраняется) и отправляет
конфиг агенту.
## Миграции
```text
Промпт: «Запусти миграции сервиса 5 и проверь результат.»
```
`migrate {app_id:5, wait:true}`; журнал последних миграций — в
`get_application` (`migration_logs`).
+109
View File
@@ -0,0 +1,109 @@
# Быстрый старт
`omnichannel-mcp` — это **MCP-сервер**: программа, которую AI-ассистент
(Claude Desktop, Cursor, Continue и любой другой MCP-клиент) запускает как
локальный процесс и через которую получает доступ к платформе Omnichannel.
Сервер не хранит состояние и не имеет UI: он лишь транслирует запросы агента в
HTTP API `config_server`.
## Что понадобится
1. **Запущенный `config_server`** (версия 1.1.0 или новее), доступный по сети.
По умолчанию — `http://<host>:5005`. Проверить: `curl http://<host>:5005/health` → `OK`.
2. **Учётная запись** `config_server` (логин/пароль администратора).
3. **MCP-клиент**, поддерживающий stdio-серверы (Claude Desktop, Cursor и др.).
4. Для сборки — **Go 1.27+** и доступ к общему тулкиту (Go-модуль
`forge-toolkit` на `git.totmin.ru`; задайте `GOPRIVATE=git.totmin.ru`).
## 1. Сборка
```bash
cd omnichannel-mcp
export GOPRIVATE=git.totmin.ru
go build -o omnichannel-mcp .
./omnichannel-mcp --health # -> ok
```
## 2. Конфигурация
Скопируйте пример и задайте адрес и креды:
```bash
cp examples/config.single-server.json config.json
```
```json
{
"servers": [
{
"alias": "prod",
"base_url": "http://10.20.30.40:5005",
"username": "${OMNI_USER}",
"password": "${OMNI_PASSWORD}"
}
],
"default": "prod",
"read_only": false
}
```
Секреты удобно держать в переменных окружения или в `config.local.json`
(подробно — [configuration.md](configuration.md)).
Проверьте конфиг (секреты маскируются) — это самая частая причина проблем:
```bash
export OMNI_USER=admin OMNI_PASSWORD='...'
./omnichannel-mcp --check-config -config config.json
```
## 3. Подключение к MCP-клиенту
Общий вид конфигурации (для любого клиента, поддерживающего `mcpServers`):
```json
{
"mcpServers": {
"omnichannel-mcp": {
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
"env": {
"OMNI_USER": "admin",
"OMNI_PASSWORD": "СЕКРЕТ"
}
}
}
}
```
Готовые сниппеты для конкретных клиентов — [mcp-clients.md](mcp-clients.md).
## 4. Проверка
Попросите ассистента:
> «Проверь подключение к Omnichannel и покажи, что сейчас на стенде.»
Ассистент вызовет `server_info` (версия API и режим), затем `overview` (сводка).
Если видите корректную версию `1.1.0` и список сервисов — всё работает.
## 5. Первый деплой
Самый быстрый сценарий для уже зарегистрированного сервиса:
> «Обнови compose сервиса `demo_web` на образ `nginx:1.27`, подними его и
> дождись результата.»
Последовательность вызовов и подробные сценарии (1 сервер, N серверов, фронт,
откат) — [deployment.md](deployment.md). Готовые промпты —
[../examples/prompts.md](../examples/prompts.md).
## Демо-стенд (без прода)
Поднять локальный `config_server` и прогнать сквозной пример:
```bash
docker compose -f examples/demo/docker-compose.yml up -d
go build -o omnichannel-mcp .
python3 examples/demo/demo.py
```
+67
View File
@@ -0,0 +1,67 @@
# Подключение к MCP-клиентам
`omnichannel-mcp` — обычный stdio-сервер. Достаточно указать команду запуска,
аргумент `-config` и (при необходимости) переменные окружения с секретами.
## Claude Desktop
Файл конфигурации:
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"omnichannel-mcp": {
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
"env": {
"OMNI_USER": "admin",
"OMNI_PASSWORD": "СЕКРЕТ"
}
}
}
}
```
## Cursor
Файл `~/.cursor/mcp.json` (или `.cursor/mcp.json` в проекте):
```json
{
"mcpServers": {
"omnichannel-mcp": {
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
"env": { "OMNI_USER": "admin", "OMNI_PASSWORD": "СЕКРЕТ" }
}
}
}
```
## Любой другой stdio-клиент
Форма одна: `command` + `args` + `env`. Минимально достаточно:
```json
{ "command": "/opt/omnichannel-mcp/omnichannel-mcp",
"args": ["-config", "/opt/omnichannel-mcp/config.json"] }
```
Если секреты лежат в `config.local.json`, переменные окружения не нужны.
## Проверка подключения
- Из терминала: `./omnichannel-mcp --check-config -config config.json` и
`./omnichannel-mcp --health`.
- В клиенте: попросите ассистента вызвать `server_info` — он покажет версию API
и режим. Затем `overview` — сводку по стенду.
## Несколько стендов
Один инстанс сервера может обслуживать несколько стендов (`servers` в конфиге).
Тогда в вызовах используется аргумент `server`. Если нужна полная изоляция
(разные пользователи/права) — запустите отдельные инстансы (разные `-config` и,
при желании, `read_only`), каждый со своим именем в `mcpServers`.
+82
View File
@@ -0,0 +1,82 @@
# Эксплуатация
`omnichannel-mcp` работает в **плоскости управления**: он видит статусы,
конфигурацию, задачи и историю, но не заглядывает внутрь контейнеров. Это
разделение осознанное — так сервер остаётся безопасным и предсказуемым.
## Что можно, а что нет
| Задача | Инструменты этого сервера | Вне него |
|---|---|---|
| Статусы сервисов и хостов | `overview`, `list_applications`, `service_map` | — |
| Аудит и разбор задач | `list_tasks`, `get_task`, `deployment_tasks` | — |
| Конфигурация compose/env, версии, откат | `get_application`, `get_config`, `env_files`, `compose_version`, `env_versions`, `restore_compose`, `restore_env_version` | — |
| Жизненный цикл сервиса | `deploy`, `restart`, `down`, `migrate` | — |
| Развёртывание релиза | `save_deployment_files`, `seed_hosts`, `fill_vars`, `download_release`, `release_job`, `start_services` | — |
| **Логи контейнеров** | — | отдельный MCP-сервер SSH/`docker logs` |
| **Ресурсы (CPU/RAM/диск)** | — | мониторинг (SSH, Zabbix, Prometheus) |
| **Данные в БД** | — | отдельный MCP-сервер для БД |
| **HTTP-проверки сервисов** | — | отдельный MCP-сервер для HTTP/веб |
Иными словами: этим сервером удобно отвечать на «что и с какой конфигурацией
развёрнуто и что сломалось при деплое», а диагностику рантайма подключайте
дополнительными MCP-серверами.
## Типовые сценарии
### «Что сейчас на стенде?»
> «Покажи сводку по стенду prod.»
`overview` вернёт health, статистику, сервисы не в статусе OK и последние
упавшие задачи — это самый быстрый «снимок».
### «Почему упал деплой?»
1. `list_tasks {status:"failed", limit:20}` — найти задачу.
2. `get_task {task_id}` — прочитать `output` (stdout/stderr). По умолчанию
оставляется **хвост** вывода, где обычно и причина; при необходимости
увеличить `output_bytes`.
3. При необходимости сравнить конфиг: `get_application {app_id}`.
### «Сервис не поднялся / зависает»
1. `get_application {app_id}` — статус, текущий compose/env, журнал миграций.
2. `list_tasks {host_ip, task_type}` — история операций по хосту.
3. Повторить контролируемо: `restart {app_id, wait:true}` и снова `get_task`.
4. Если внутри сервиса — переключиться на SSH-инструменты (логи).
### «Нужно поправить конфигурацию»
1. `get_config {app_id}` / `get_application {app_id}` — текущее.
2. `set_env` / `set_compose` — изменить (создаётся версия).
3. `deploy` или `restart` — применить на хосте.
4. Проверить `get_task`/`overview`. При неудаче — откат (`restore_*`).
### «Проверить релиз»
- `release_job` — статус фоновой выгрузки (фаза, прогресс образов).
- `deployment_tasks` — последние sync/start-задачи.
- `ip_match` — корректно ли сопоставлены хосты и агенты.
### «Обслуживание»
- `stats` — сколько версий и логов накопилось.
- `cleanup_old_versions {days:180, confirm:true}` — удалить устаревшие версии
(текущие не трогаются).
## Полезные фильтры
- `list_tasks` ограничивает выборку (`limit` ≤ 500) и фильтрует по `host_ip`,
`status`, `task_type`.
- `list_applications {include_presence:true}` включает служебные записи хостов
(хост без развёрнутых сервисов).
## Ограничения и защита
- Длинные выводы обрезаются (`max_output_bytes`), чтобы не переполнить контекст
ассистента.
- Ожидание задач (`wait`) ограничено `task_poll_max_sec`; если задача не успела —
вернётся её текущее состояние, а не ошибка.
- Мутации сериализуются на сервер (`max_concurrent_mutations`) — параллельные
`restart` не наложатся друг на друга.
+91
View File
@@ -0,0 +1,91 @@
# Безопасность
`omnichannel-mcp` даёт ассистенту право управлять платформой, поэтому
безопасность строится в несколько независимых слоёв: конфигурация модуля,
подтверждения, и ограничения на стороне `config_server`.
## Модель угроз
- **Ошибочный или злонамеренный вызов управляющего инструмента** (остановить
прод, залить чужой конфиг).
- **Утечка секретов** (пароли, токен GitLab) в логи, в контекст модели, в
репозиторий.
- **Доступ не туда** (SSRF, подмена хоста стенда).
- **Чрезмерные привилегии** (учётная запись только для чтения там, где нужен
мониторинг).
## Слои защиты
### 1. Учётные данные и секреты
- Пароли и токены — только из `${VAR}` окружения или `config.local.json`
(в `.gitignore`). В репозитории — лишь шаблон.
- `--check-config` печатает конфиг с маской секретов — можно безопасно проверять
настройку и выкладывать вывод в тикеты.
- Секреты не логируются: логи идут в stderr с маскированием, токен GitLab в
выводе не отражается.
### 2. Режим «только чтение»
- `read_only: true` (значение по умолчанию) запрещает все изменяющие инструменты.
- Для наблюдательных агентов поднимайте **отдельный инстанс** с `read_only: true`
и отдельной read-only учётной записью:
```json
{ "servers": [{ "alias": "prod", "base_url": "http://…",
"readonly_username": "${OMNI_RO_USER}",
"readonly_password": "${OMNI_RO_PASSWORD}" }],
"read_only": true }
```
- Если заданы `readonly_*`-креды, read-инструменты ходят именно под ними —
это least-privilege на стороне модуля.
### 3. Подтверждения (`confirm`)
Разрушительные операции требуют `confirm="true"`:
- `down`, `restore_compose`, `restore_env_version`, `update_front`,
`download_release`, `cleanup_old_versions`.
Это второй слой: даже при включённом `read_only=false` ассистент обязан явно
подтвердить опасное действие. Дополнительно настройте политику подтверждений
вашего MCP-клиента (человеческое «да/нет» на управляющие инструменты).
### 4. Изоляция сети (`allow_hosts`)
Если задан `allow_hosts`, `base_url` любого сервера обязан быть на этих хостах —
защита от опечаток и подмены адреса.
### 5. Файловый jail (`front_roots`)
`update_front` принимает только каталоги внутри `front_roots`; при проверке
разрешаются симлинки и отсекаются побеги (`..`, ссылки наружу). Пусто —
загрузка фронта запрещена полностью.
### 6. Ограничение нагрузки
- Таймауты (`timeout_sec`, `upload_timeout_sec`), лимиты вывода
(`max_output_bytes`) и размера загрузки (`max_upload_bytes`).
- Семафор мутаций (`max_concurrent_mutations`) предотвращает наложение операций.
- GET-запросы повторяются при сетевом сбое; **мутации не повторяются** — действие
не выполнится дважды.
## Что модуль НЕ делает
- Не работает с агентскими эндпоинтами `config_server`
(`/api/register`, `/api/tasks/<host>`) — только с пользовательским API.
- Не хранит состояние и не пишет секреты на диск.
- Не выполняет код на хостах: все действия выполняет агент `config_server`.
## Рекомендации оператору
1. Заведите **отдельные учётные записи**: read-only для мониторинга,
полную — только для деплой-агента.
2. Для мониторинга запускайте инстанс с `read_only: true`.
3. Держите секреты в окружении/`config.local.json`, не в `config.json`.
4. Включите подтверждения управляющих инструментов в MCP-клиенте.
5. Ограничьте сетевой доступ к `config_server` (VPN/private network); наружу
порт 5005 не публикуйте.
6. Об уязвимостях сообщайте в службу безопасности компании (не публикуйте в
общих трекерах).
+80
View File
@@ -0,0 +1,80 @@
# Справочник инструментов
36 инструментов, сгруппированы по фазам. Общие аргументы:
- `server` — алиас стенда из конфига (по умолчанию `default`). Необязателен.
- `app_id` — идентификатор сервиса (см. `list_applications`).
- `confirm` — `true` для разрушительных операций.
Обозначения: **R** — только чтение; **W** — изменяет; **W!** — изменяет и
требует `confirm="true"`.
## Наблюдение (observe)
| Инструмент | Тип | Аргументы | Что делает |
|---|---|---|---|
| `server_health` | R | — | Живость `config_server` (`GET /health`). |
| `whoami` | R | — | Кто авторизован. |
| `server_info` | R | — | Версия API (capability-probe), адрес, режим `read_only`. Вызывать первым. |
| `list_applications` | R | `include_presence` | Список сервисов с хостами и статусами. |
| `get_application` | R | `app_id` | Детали: compose, версии, env-файлы, журнал миграций. |
| `get_config` | R | `app_id` | Текущие compose и env сервиса. |
| `env_files` | R | `app_id` | Имена текущих env-файлов. |
| `compose_version` | R | `version_id` | Содержимое версии compose. |
| `env_version` | R | `version_id` | Содержимое версии env-файла. |
| `env_versions` | R | `app_id`, `filename` | Все версии конкретного env-файла. |
| `list_tasks` | R | `host_ip`, `status`, `task_type`, `limit` | Задачи агентов (аудит, поиск failed). |
| `get_task` | R | `task_id`, `wait`, `tail`, `output_bytes` | Статус/вывод задачи; `wait=true` ждёт завершения. |
| `get_deployment_files` | R | — | schema, manifest, prebuild_vars, ip_overrides. |
| `ip_match` | R | — | Сопоставление IP схемы и агентов. |
| `release_job` | R | — | Статус фоновой выгрузки релиза. |
| `deployment_tasks` | R | — | Последние release-задачи (sync/start). |
| `stats` | R | — | Счётчики версий compose/env и логов миграций. |
| `service_map` | R | — | Карта хостов и сервисов. |
| `overview` | R | — | Сводка: health, stats, сервисы не в OK, failed-задачи. |
| `release_status` | R | — | Сводка развёртывания: job, задачи, приложения, IP. |
## Конфигурация (configure)
| Инструмент | Тип | Аргументы | Что делает |
|---|---|---|---|
| `set_compose` | W | `app_id`, `content` | Задать compose, создать версию, отправить агенту. |
| `set_env` | W | `app_id`, `filename`, `content` | Задать env-файл, создать версию, отправить агенту. |
| `restore_compose` | W! | `version_id`, `confirm` | Восстановить версию compose. |
| `restore_env_version` | W! | `version_id`, `confirm` | Восстановить версию env-файла. |
| `update_config` | W | `app_id` | Повторно отправить текущий конфиг агенту. |
## Жизненный цикл (operate)
| Инструмент | Тип | Аргументы | Что делает |
|---|---|---|---|
| `deploy` | W | `app_id`, `wait` | `docker-compose up -d`. |
| `restart` | W | `app_id`, `wait` | `down` + `up -d`. |
| `down` | W! | `app_id`, `confirm`, `wait` | `docker-compose down`. |
| `migrate` | W | `app_id`, `wait` | `docker-compose run migration`. |
| `update_front` | W! | `app_id`, `build_dir`, `confirm` | Загрузить сборку фронта (каталог внутри `front_roots`). |
## Развёртывание релиза (release)
| Инструмент | Тип | Аргументы | Что делает |
|---|---|---|---|
| `save_deployment_files` | W | `schema`, `manifest`, `prebuild_vars`, `ip_overrides` | Сохранить файлы развёртывания (только переданные поля). |
| `seed_hosts` | W | — | Создать хосты из schema.json. |
| `fill_vars` | W | — | Заполнить prebuild_vars из схемы. |
| `download_release` | W! | `load_images`, `gitlab_token`, `confirm` | Выгрузить релиз; `load_images=true` — фон. |
| `start_services` | W | — | Создать задачи запуска сервисов по схеме. |
## Обслуживание (maintain)
| Инструмент | Тип | Аргументы | Что делает |
|---|---|---|---|
| `cleanup_old_versions` | W! | `days`, `confirm` | Удалить версии/логи старше N дней. |
## Результаты и ошибки
- Успех — читаемый JSON.
- **Доменная ошибка** (неверный `app_id`, нет confirm, отказ сервера) возвращается
как результат инструмента с текстом — ассистент видит её и может исправить.
- **Инфраструктурная ошибка** (нет сети) — как ошибка протокола.
- Длинный вывод задачи обрезается (по умолчанию с сохранением хвоста — там
обычно причина сбоя); `get_task` принимает `output_bytes` и `tail`.
+22
View File
@@ -0,0 +1,22 @@
{
"servers": [
{
"alias": "prod",
"base_url": "http://10.20.30.40:5005",
"username": "${OMNI_PROD_USER}",
"password": "${OMNI_PROD_PASSWORD}",
"readonly_username": "${OMNI_PROD_RO_USER}",
"readonly_password": "${OMNI_PROD_RO_PASSWORD}",
"front_roots": ["/srv/omni-front-builds"]
},
{
"alias": "stage",
"base_url": "http://10.20.31.40:5005",
"username": "${OMNI_STAGE_USER}",
"password": "${OMNI_STAGE_PASSWORD}"
}
],
"default": "prod",
"read_only": false,
"allow_hosts": ["10.20.30.40", "10.20.31.40"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"servers": [
{
"alias": "prod",
"base_url": "http://10.20.30.40:5005",
"readonly_username": "${OMNI_RO_USER}",
"readonly_password": "${OMNI_RO_PASSWORD}"
}
],
"default": "prod",
"read_only": true
}
+13
View File
@@ -0,0 +1,13 @@
{
"servers": [
{
"alias": "prod",
"base_url": "http://10.20.30.40:5005",
"username": "${OMNI_USER}",
"password": "${OMNI_PASSWORD}",
"front_roots": ["/srv/omni-front-builds"]
}
],
"default": "prod",
"read_only": false
}
+46
View File
@@ -0,0 +1,46 @@
# Демо и e2e
Два сценария для локальной проверки без прода.
## 1. Сквозной пример через MCP (`demo.py`)
Поднимает только `config_server`; задачи создаются, но их никто не исполняет —
показывает вызовы инструментов и ответы.
```bash
docker compose -f examples/demo/docker-compose.yml up -d
go build -o omnichannel-mcp .
python3 examples/demo/demo.py
```
## 2. Полный e2e деплоя с агентом (`e2e.py`)
Дополнительно запускает настоящий **config-agent**, который опрашивает
`config_server` и реально выполняет задачи через `docker-compose`. Проверяется
жизненный цикл: `set_env` → `set_compose` → `deploy` → `restart` → `down`, а
также защита (`down` без `confirm` отклоняется).
```bash
docker compose -f examples/demo/docker-compose.yml up -d
go build -o omnichannel-mcp .
python3 examples/demo/e2e.py
```
Что делает `e2e.py`:
1. создаёт каталог `/tmp/omni-e2e/opt/demo_e2e` с `docker-compose.yml`;
2. поднимает контейнер `omni-e2e-agent` (образ `config-agent:v1.1.0`) с
проброшенным `docker.sock` и `/opt`;
3. ждёт регистрации приложения агентом;
4. гоняет жизненный цикл через MCP и проверяет состояние контейнеров;
5. удаляет тестовый агент.
Требуется доступ к `registry.devlexicom.ru` (образ агента).
## Очистка
```bash
docker compose -f examples/demo/docker-compose.yml down -v
docker rm -f omni-e2e-agent 2>/dev/null; docker network rm demo_e2e_default 2>/dev/null
rm -rf /tmp/omni-e2e
```
+13
View File
@@ -0,0 +1,13 @@
{
"servers": [
{
"alias": "demo",
"base_url": "http://127.0.0.1:5005",
"username": "admin",
"password": "LexP@ssw0rd_1"
}
],
"default": "demo",
"read_only": false,
"allow_hosts": ["127.0.0.1"]
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Сквозной демо-пример использования omnichannel-mcp.
Что делает:
1) засевает демо-сервис в локальный config_server (агентский эндпоинт);
2) запускает omnichannel-mcp по stdio;
3) выполняет типичный сценарий эксплуатации/деплоя:
server_info -> list_applications -> get_application -> set_env ->
set_compose -> deploy -> get_task -> overview.
Зависимостей нет (только stdlib). Требуется поднятый стенд:
docker compose -f examples/demo/docker-compose.yml up -d
и собранный бинарник:
go build -o omnichannel-mcp .
Запуск: python3 examples/demo/demo.py
"""
import json
import os
import select
import subprocess
import sys
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE)) # корень репозитория
BINARY = os.path.join(ROOT, "omnichannel-mcp")
CONFIG = os.path.join(HERE, "config.demo.json")
BASE_URL = "http://127.0.0.1:5005"
AGENT_SECRET = "demo-agent-secret"
DEMO_APP_ID = 1
def http_json(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE_URL + path, data=data, method=method,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
def seed_demo_app():
"""Регистрирует демо-сервис так, как это делает агент ноды."""
body = {
"secret": AGENT_SECRET,
"name": "demo_web",
"host_ip": "10.0.0.10",
"hostname": "demo-node",
"path": "/opt/omnichannel/demo_web",
"status": "OK",
"compose_content": "services:\n web:\n image: nginx:1.25\n",
"env_files": {".env": "FOO=bar\n"},
}
result = http_json("POST", "/api/register", body)
print(f"засеян демо-сервис demo_web: app_id={result.get('app_id')}")
class MCP:
"""Минимальный MCP-клиент по stdio (JSON-RPC, newline-delimited)."""
def __init__(self):
env = dict(os.environ, OMNI_LOG_LEVEL="warn")
self.proc = subprocess.Popen(
[BINARY, "-config", CONFIG], cwd=ROOT, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
self.id = 0
self._request("initialize", {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "demo", "version": "0"},
})
self._notify("notifications/initialized", {})
def _id(self):
self.id += 1
return self.id
def _send(self, obj):
self.proc.stdin.write(json.dumps(obj) + "\n")
self.proc.stdin.flush()
def _recv(self, want_id, timeout=30):
while True:
ready, _, _ = select.select([self.proc.stdout], [], [], timeout)
if not ready:
raise TimeoutError(f"нет ответа на id={want_id}")
line = self.proc.stdout.readline()
if not line:
raise RuntimeError("сервер закрыл stdout:\n" + self.proc.stderr.read())
msg = json.loads(line)
if msg.get("id") == want_id:
return msg
def _request(self, method, params):
rid = self._id()
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
return self._recv(rid)
def _notify(self, method, params):
self._send({"jsonrpc": "2.0", "method": method, "params": params})
def tools(self):
return self._request("tools/list", {})["result"]["tools"]
def call(self, name, arguments):
resp = self._request("tools/call", {"name": name, "arguments": arguments})
result = resp.get("result", {})
text = "".join(c.get("text", "") for c in result.get("content", []))
return text, result.get("isError", False)
def close(self):
self.proc.stdin.close()
self.proc.terminate()
def show(title, name, arguments, mcp):
text, is_error = mcp.call(name, arguments)
print(f"\n=== {title} ({'ошибка' if is_error else 'ok'}) ===")
print(text[:600])
def main():
seed_demo_app()
mcp = MCP()
tools = mcp.tools()
print(f"\nMCP-сервер отдал инструментов: {len(tools)}")
show("Версия API и режим", "server_info", {}, mcp)
show("Список сервисов", "list_applications", {}, mcp)
show("Детали демо-сервиса", "get_application", {"app_id": DEMO_APP_ID}, mcp)
show("Обновляем env", "set_env",
{"app_id": DEMO_APP_ID, "filename": ".env", "content": "FOO=baz\n"}, mcp)
show("Обновляем compose", "set_compose",
{"app_id": DEMO_APP_ID, "content": "services:\n web:\n image: nginx:1.27\n"}, mcp)
deploy_text, _ = mcp.call("deploy", {"app_id": DEMO_APP_ID})
print("\n=== Запуск сервиса (deploy) ===")
print(deploy_text[:400])
try:
task_id = json.loads(deploy_text)["task_id"]
show("Статус задачи деплоя", "get_task", {"task_id": task_id}, mcp)
except (ValueError, KeyError):
print("(не удалось извлечь task_id — пропускаем get_task)")
show("Сводка состояния стенда", "overview", {}, mcp)
mcp.close()
print("\nDemo завершён.")
if __name__ == "__main__":
if not os.path.exists(BINARY):
sys.exit(f"нет бинарника {BINARY}: сначала выполните `go build -o omnichannel-mcp .`")
main()
+55
View File
@@ -0,0 +1,55 @@
# Демонстрационный стенд config_server для локальной проверки omnichannel-mcp.
#
# Поднимает config_server v1.1.0 + PostgreSQL. Данные — эфемерные.
#
# docker compose -f examples/demo/docker-compose.yml up -d
# curl -s http://localhost:5005/health # -> OK
# python3 examples/demo/demo.py # сквозной пример деплоя через MCP
# docker compose -f examples/demo/docker-compose.yml down -v # удалить и данные
#
# init-db.sh — схема БД и учётная запись admin/LexP@ssw0rd_1 (из поставки
# config_server). Для демо этого достаточно.
name: omni-mcp-demo
services:
db:
image: registry.devlexicom.ru/templates/docker/docker_images/postgres:15
environment:
POSTGRES_DB: config_server
POSTGRES_USER: config_user
POSTGRES_PASSWORD: 8fj439fjdk48
TZ: Europe/Moscow
PGTZ: Europe/Moscow
volumes:
- db_data:/var/lib/postgresql/data
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U config_user -d config_server"]
interval: 5s
timeout: 5s
retries: 10
config-server:
image: registry.devlexicom.ru/omnichannel_platform/config_server/config-server:v1.1.0
ports:
- "5005:5000"
environment:
POSTGRES_DB: config_server
POSTGRES_USER: config_user
POSTGRES_PASSWORD: 8fj439fjdk48
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
SECRET_KEY: demo-secret-key
AGENT_SECRET: demo-agent-secret
DEPLOYMENT_DATA_DIR: /data/deployment
TZ: Europe/Moscow
volumes:
- server_data:/data/deployment
depends_on:
db:
condition: service_healthy
volumes:
db_data:
server_data:
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""E2E-тест деплоя omnichannel-mcp с реальным config-agent.
Поднимает контейнер config-agent, который опрашивает config_server и реально
выполняет задачи через docker-compose, затем гоняет жизненный цикл сервиса
через MCP: set_env -> set_compose -> deploy -> restart -> down.
Требуется:
- поднятый стенд: docker compose -f examples/demo/docker-compose.yml up -d
- собранный бинарник: go build -o omnichannel-mcp .
- доступ к образу config-agent (registry.devlexicom.ru).
Запуск: python3 examples/demo/e2e.py
"""
import json
import os
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from demo import MCP # noqa: E402 (общий минимальный MCP-клиент)
AGENT_IMAGE = "registry.devlexicom.ru/omnichannel_platform/config_server/config-agent:v1.1.0"
AGENT_NAME = "omni-e2e-agent"
NETWORK = "omni-mcp-demo_default"
OPT_DIR = "/tmp/omni-e2e/opt"
APP = "demo_e2e"
APP_DIR = os.path.join(OPT_DIR, APP)
HOST_IP = "10.0.0.10"
INITIAL_COMPOSE = """services:
web:
image: alpine:3.20
command: ["sleep", "3600"]
"""
UPDATED_COMPOSE = """services:
web:
image: alpine:3.20
command: ["sleep", "7200"]
"""
def sh(*args, check=True):
return subprocess.run(args, capture_output=True, text=True, check=check)
def docker(*args, check=True):
return sh("docker", *args, check=check)
def prepare_app_dir():
os.makedirs(APP_DIR, exist_ok=True)
with open(os.path.join(APP_DIR, "docker-compose.yml"), "w") as fh:
fh.write(INITIAL_COMPOSE)
def start_agent():
docker("rm", "-f", AGENT_NAME, check=False)
docker(
"run", "-d", "--name", AGENT_NAME,
"--network", NETWORK,
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", "/usr/bin/docker:/usr/bin/docker:ro",
"-v", f"{OPT_DIR}:/opt",
"-e", "SERVER_URL=http://config-server:5000",
"-e", "AGENT_SECRET=demo-agent-secret",
"-e", f"AGENT_REGISTER_IP={HOST_IP}",
"-e", "TASK_CHECK_INTERVAL=3",
"-e", "SCAN_INTERVAL=15",
AGENT_IMAGE,
)
print(f"config-agent запущен: {AGENT_NAME}")
def stop_agent():
docker("rm", "-f", AGENT_NAME, check=False)
print("config-agent остановлен")
def find_app(mcp, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
text, _ = mcp.call("list_applications", {})
try:
apps = json.loads(text)
except ValueError:
apps = []
for app in apps:
if app.get("name") == APP:
return app["id"]
time.sleep(2)
raise RuntimeError(f"агент не зарегистрировал приложение {APP} за {timeout}s")
def wait_task(mcp, task_id, timeout=120):
deadline = time.time() + timeout
while time.time() < deadline:
text, _ = mcp.call("get_task", {"task_id": task_id})
task = json.loads(text)
if task["status"] in ("completed", "failed"):
return task
time.sleep(2)
raise RuntimeError(f"задача {task_id} не завершилась за {timeout}s")
def call_json(mcp, tool, args):
text, is_error = mcp.call(tool, args)
if is_error:
raise RuntimeError(f"{tool}: {text}")
return json.loads(text)
def container_running(app):
out = docker("ps", "--filter", f"name={app}", "--format", "{{.Names}}").stdout
return app in out
def wait_file_contains(path, needle, timeout=40):
deadline = time.time() + timeout
while time.time() < deadline:
try:
if needle in open(path).read():
return True
except OSError:
pass
time.sleep(2)
return False
CHECKS = []
def check(name, ok):
CHECKS.append((name, ok))
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
def main():
mcp = MCP()
try:
app_id = find_app(mcp)
print(f"Приложение найдено: {APP} (app_id={app_id})")
# 1. Изменение конфигурации через MCP и доставка агентом.
call_json(mcp, "set_env", {"app_id": app_id, "filename": ".env", "content": "E2E=1\n"})
call_json(mcp, "set_compose", {"app_id": app_id, "content": UPDATED_COMPOSE})
check("set_env/set_compose приняты сервером", True)
check("compose доставлен агентом на диск",
wait_file_contains(os.path.join(APP_DIR, "docker-compose.yml"), "7200"))
# 2. Deploy.
ack = call_json(mcp, "deploy", {"app_id": app_id, "wait": True})
task = ack.get("task", {})
check("deploy завершён (completed)", task.get("status") == "completed")
check("контейнер demo_e2e поднят", container_running(APP))
print(f" вывод deploy: {task.get('output','')[:160].strip()}")
# 3. Restart.
ack = call_json(mcp, "restart", {"app_id": app_id, "wait": True})
check("restart завершён (completed)", ack.get("task", {}).get("status") == "completed")
check("контейнер жив после restart", container_running(APP))
# 4. Down.
ack = call_json(mcp, "down", {"app_id": app_id, "confirm": True, "wait": True})
check("down завершён (completed)", ack.get("task", {}).get("status") == "completed")
time.sleep(2)
check("контейнер остановлен", not container_running(APP))
# 5. Защита без confirm.
text, is_error = mcp.call("down", {"app_id": app_id})
check("down без confirm отклонён", is_error)
finally:
mcp.close()
stop_agent()
failed = [name for name, ok in CHECKS if not ok]
print(f"\nE2E: {len(CHECKS) - len(failed)}/{len(CHECKS)} проверок пройдено")
if failed:
print("Провалено: " + "; ".join(failed))
sys.exit(1)
if __name__ == "__main__":
prepare_app_dir()
start_agent()
main()
+272
View File
@@ -0,0 +1,272 @@
#!/bin/bash
set -e
ADMIN_LOGIN=${ADMIN_LOGIN:-admin}
ADMIN_PASSWORD=${ADMIN_PASSWORD:-LexP@ssw0rd_1}
# Ждем пока PostgreSQL будет готов принимать подключения
until pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done
# Создаем базу данных и пользователя если не существуют
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Создаем расширение если не существует
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Даем все права пользователю на базу данных
GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
-- Создаем таблицы если их нет (для первоначальной инициализации)
DO \$\$
BEGIN
-- Таблица приложений
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'application') THEN
CREATE TABLE application (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
host_ip VARCHAR(45) NOT NULL,
hostname VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
active BOOLEAN DEFAULT TRUE,
status VARCHAR(20) DEFAULT 'UNKNOWN',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Индексы для application
CREATE INDEX idx_application_host_ip ON application(host_ip);
CREATE INDEX idx_application_active ON application(active);
CREATE INDEX idx_application_status ON application(status);
CREATE INDEX idx_application_created_at ON application(created_at);
RAISE NOTICE 'Table application created successfully';
ELSE
RAISE NOTICE 'Table application already exists';
END IF;
-- Гарантируем наличие колонки status в application
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'application' AND column_name = 'status'
) THEN
ALTER TABLE application ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN';
UPDATE application SET status = 'UNKNOWN' WHERE status IS NULL;
CREATE INDEX IF NOT EXISTS idx_application_status ON application(status);
RAISE NOTICE 'Added status column to application table';
END IF;
-- Таблица docker-compose файлов
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'compose_file') THEN
CREATE TABLE compose_file (
id SERIAL PRIMARY KEY,
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_current BOOLEAN DEFAULT TRUE
);
-- Индексы для compose_file
CREATE INDEX idx_compose_file_application_id ON compose_file(application_id);
CREATE INDEX idx_compose_file_is_current ON compose_file(is_current);
CREATE INDEX idx_compose_file_created_at ON compose_file(created_at);
RAISE NOTICE 'Table compose_file created successfully';
ELSE
RAISE NOTICE 'Table compose_file already exists';
END IF;
-- Таблица env файлов (обновленная структура)
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'env_file') THEN
CREATE TABLE env_file (
id SERIAL PRIMARY KEY,
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
filename VARCHAR(100) NOT NULL DEFAULT '.env',
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_current BOOLEAN DEFAULT TRUE
);
-- Уникальный constraint ТОЛЬКО для текущих версий (используем partial unique index)
CREATE UNIQUE INDEX unique_current_env ON env_file (application_id, filename)
WHERE is_current = true;
-- Индексы для env_file
CREATE INDEX idx_env_file_application_id ON env_file(application_id);
CREATE INDEX idx_env_file_filename ON env_file(filename);
CREATE INDEX idx_env_file_is_current ON env_file(is_current);
CREATE INDEX idx_env_file_created_at ON env_file(created_at);
RAISE NOTICE 'Table env_file created successfully';
ELSE
-- Если таблица уже существует, проверяем наличие поля filename
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'env_file' AND column_name = 'filename') THEN
-- Добавляем поле filename
ALTER TABLE env_file ADD COLUMN filename VARCHAR(100) NOT NULL DEFAULT '.env';
-- Создаем индексы для нового поля
CREATE INDEX idx_env_file_filename ON env_file(filename);
RAISE NOTICE 'Added filename column to env_file table';
ELSE
RAISE NOTICE 'Table env_file already exists with filename column';
END IF;
-- Удаляем старый constraint если он существует
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'env_file' AND constraint_name = 'unique_current_env') THEN
ALTER TABLE env_file DROP CONSTRAINT unique_current_env;
RAISE NOTICE 'Dropped old unique_current_env constraint';
END IF;
-- Удаляем старый index если он существует
IF EXISTS (SELECT 1 FROM pg_indexes
WHERE tablename = 'env_file' AND indexname = 'unique_current_env') THEN
DROP INDEX unique_current_env;
RAISE NOTICE 'Dropped old unique_current_env index';
END IF;
-- Создаем новый partial unique index
CREATE UNIQUE INDEX unique_current_env ON env_file (application_id, filename)
WHERE is_current = true;
RAISE NOTICE 'Created new partial unique index for current env files';
END IF;
-- Таблица логов миграций
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'migration_log') THEN
CREATE TABLE migration_log (
id SERIAL PRIMARY KEY,
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
command VARCHAR(500) NOT NULL,
status VARCHAR(50) NOT NULL,
output TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Индексы для migration_log
CREATE INDEX idx_migration_log_application_id ON migration_log(application_id);
CREATE INDEX idx_migration_log_created_at ON migration_log(created_at);
RAISE NOTICE 'Table migration_log created successfully';
ELSE
RAISE NOTICE 'Table migration_log already exists';
END IF;
-- Таблица задач агента
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'agent_task') THEN
CREATE TABLE agent_task (
id SERIAL PRIMARY KEY,
task_id VARCHAR(36) UNIQUE NOT NULL,
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
task_type VARCHAR(50) NOT NULL,
command VARCHAR(500) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
host_ip VARCHAR(45) NOT NULL,
payload TEXT,
output TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
-- Создаем индексы для часто используемых полей
CREATE INDEX idx_agent_task_task_id ON agent_task(task_id);
CREATE INDEX idx_agent_task_application_id ON agent_task(application_id);
CREATE INDEX idx_agent_task_status ON agent_task(status);
CREATE INDEX idx_agent_task_host_ip ON agent_task(host_ip);
CREATE INDEX idx_agent_task_created_at ON agent_task(created_at);
CREATE INDEX idx_agent_task_completed_at ON agent_task(completed_at);
-- Комментарии к таблице и полям
COMMENT ON TABLE agent_task IS 'Таблица для хранения задач агента развертывания';
COMMENT ON COLUMN agent_task.task_id IS 'Уникальный идентификатор задачи (UUID)';
COMMENT ON COLUMN agent_task.task_type IS 'Тип задачи: deploy, migration, update_config';
COMMENT ON COLUMN agent_task.status IS 'Статус задачи: pending, in_progress, completed, failed';
COMMENT ON COLUMN agent_task.payload IS 'Дополнительные данные для задачи в формате JSON';
COMMENT ON COLUMN agent_task.output IS 'Вывод выполнения команды';
RAISE NOTICE 'Table agent_task created successfully';
ELSE
RAISE NOTICE 'Table agent_task already exists';
END IF;
-- Таблица пользователей веб-интерфейса
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'auth_user') THEN
CREATE TABLE auth_user (
id SERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(128) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_auth_user_username ON auth_user(username);
CREATE INDEX idx_auth_user_active ON auth_user(is_active);
RAISE NOTICE 'Table auth_user created successfully';
ELSE
RAISE NOTICE 'Table auth_user already exists';
END IF;
-- Обновляем длину поля path в application если нужно
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'application' AND column_name = 'path'
AND character_maximum_length < 500) THEN
ALTER TABLE application ALTER COLUMN path TYPE VARCHAR(500);
RAISE NOTICE 'Updated application.path column length to 500';
END IF;
-- Обновляем длину поля command в migration_log если нужно
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'migration_log' AND column_name = 'command'
AND character_maximum_length < 500) THEN
ALTER TABLE migration_log ALTER COLUMN command TYPE VARCHAR(500);
RAISE NOTICE 'Updated migration_log.command column length to 500';
END IF;
-- Обновляем длину поля command в agent_task если нужно
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'agent_task' AND column_name = 'command'
AND character_maximum_length < 500) THEN
ALTER TABLE agent_task ALTER COLUMN command TYPE VARCHAR(500);
RAISE NOTICE 'Updated agent_task.command column length to 500';
END IF;
END
\$\$;
-- Создаем функцию для автоматического обновления updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS \$\$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
\$\$ language 'plpgsql';
-- Создаем триггер для автоматического обновления updated_at в application
DROP TRIGGER IF EXISTS update_application_updated_at ON application;
CREATE TRIGGER update_application_updated_at
BEFORE UPDATE ON application
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Анализируем таблицы для оптимизации
ANALYZE application;
ANALYZE compose_file;
ANALYZE env_file;
ANALYZE migration_log;
ANALYZE agent_task;
ANALYZE auth_user;
-- Инициализация единственной учетной записи (логин/пароль задаются env ADMIN_LOGIN/ADMIN_PASSWORD)
INSERT INTO auth_user (username, password_hash, is_active)
VALUES ('$ADMIN_LOGIN', md5('$ADMIN_PASSWORD'), TRUE)
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
is_active = TRUE;
EOSQL
echo "Database initialization completed!"
+56
View File
@@ -0,0 +1,56 @@
# Примеры промптов
Готовые формулировки для оператора: что написать агенту, чтобы он через
`omnichannel-mcp` выполнил типовую задачу. В скобках — инструменты, которые
агент вызовет. Замените хост/имена/пути на свои.
## Наблюдение и диагностика
- «Покажи, что сейчас происходит на стенде `prod`.» → `overview`
- «Какие сервисы не в статусе OK?» → `list_applications` (или `overview`)
- «Покажи детали сервиса `user_system_front`.» → `get_application`
- «Покажи последние упавшие задачи за сегодня.» → `list_tasks {status:"failed"}`
- «Покажи хвост вывода задачи `0c9f...`.» → `get_task {output_bytes:4000}`
- «Все ли IP из схемы сопоставлены агентам?» → `ip_match`
## Быстрый деплой на 1 сервер
- «Задеплой сервис `demo_web`: обнови compose на образ `nginx:1.27`, подними и
дождись результата.» →
`set_compose` → `deploy` → `get_task {wait:true}` → `overview`
- «Перезапусти сервис `user_system`, затем проверь, что задачи прошли без
ошибок.» → `restart` → `list_tasks`
## Деплой релиза на несколько серверов
- «Загрузи релиз на все хосты схемы: сохрани schema.json и manifest.json,
разложи хосты, скачай релиз с образами, дождись завершения и запусти
сервисы.» →
`save_deployment_files` → `seed_hosts` → `download_release {load_images:true, confirm:true}`
→ `release_job` (опрос) → `start_services` → `deployment_tasks` → `get_task`
## Обновление фронта
- «Обнови фронт сервиса `chat_widget` из каталога `/srv/builds/chat_widget`.» →
`update_front {app_id:…, build_dir:"/srv/builds/chat_widget", confirm:true}`
→ `get_task {wait:true}`
## Откат и обслуживание
- «Откати compose сервиса до версии `12`.» →
`get_application` (найти id версии) → `restore_compose {version_id:12, confirm:true}`
- «Верни env-файл `.env` сервиса к версии `7`.» →
`env_versions` → `restore_env_version {version_id:7, confirm:true}`
- «Почисти версии конфигураций старше 180 дней.» →
`cleanup_old_versions {days:180, confirm:true}`
## Мультистенд
- «Покажи статус стендов `prod` и `stage` и сравни.» →
`overview {server:"prod"}` + `overview {server:"stage"}`
- «На `stage` откати последний неудачный деплой, на `prod` ничего не трогай.» →
работа с `server:"stage"`
> Опасные операции (`down`, `restore_*`, `update_front`, `download_release`,
> `cleanup_old_versions`) требуют `confirm="true"` — агент обязан спросить
> подтверждение явно.
+19
View File
@@ -0,0 +1,19 @@
module omnichannel-mcp
go 1.27.1
require (
git.totmin.ru/en2zmax/forge-toolkit v0.1.0
github.com/modelcontextprotocol/go-sdk v1.7.0
)
require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+26
View File
@@ -0,0 +1,26 @@
git.totmin.ru/en2zmax/forge-toolkit v0.1.0 h1:d4p1mDzPwG/CuehTXVlyeT0vAWkQmzHG83NLmqUxkAo=
git.totmin.ru/en2zmax/forge-toolkit v0.1.0/go.mod h1:4LVtO/yq0SsTP6opDYS32JiDNC/tBBIAwGLGGw/D88Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+67
View File
@@ -0,0 +1,67 @@
package configserver
import (
"context"
"net/url"
"strconv"
)
// api_configure.go — изменение конфигурации сервиса (compose/env) и её версий.
// SetCompose устанавливает текущий docker-compose и отправляет его агенту.
func (a *API) SetCompose(ctx context.Context, appID int, content string) (*ConfigAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &ConfigAck{}
body := map[string]string{"content": content}
if err := a.Sess.postJSON(ctx, "/api/compose/"+strconv.Itoa(appID), body, out); err != nil {
return nil, err
}
return out, nil
}
// SetEnv устанавливает текущий env-файл и отправляет его агенту.
func (a *API) SetEnv(ctx context.Context, appID int, filename, content string) (*ConfigAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &ConfigAck{}
body := map[string]string{"content": content}
path := "/api/env/" + strconv.Itoa(appID) + "/" + url.PathEscape(filename)
if err := a.Sess.postJSON(ctx, path, body, out); err != nil {
return nil, err
}
return out, nil
}
// RestoreCompose восстанавливает версию compose (создаёт новую текущую версию).
func (a *API) RestoreCompose(ctx context.Context, versionID int) (*StatusAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &StatusAck{}
if err := a.Sess.postJSON(ctx, "/api/restore_compose/"+strconv.Itoa(versionID), nil, out); err != nil {
return nil, err
}
return out, nil
}
// RestoreEnvVersion восстанавливает версию env-файла.
func (a *API) RestoreEnvVersion(ctx context.Context, versionID int) (*StatusAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &StatusAck{}
if err := a.Sess.postJSON(ctx, "/api/restore_env_version/"+strconv.Itoa(versionID), nil, out); err != nil {
return nil, err
}
return out, nil
}
// UpdateConfig повторно отправляет агенту текущую конфигурацию из БД.
func (a *API) UpdateConfig(ctx context.Context, appID int) (*TaskAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &TaskAck{}
if err := a.Sess.postJSON(ctx, "/api/update_config/"+strconv.Itoa(appID), nil, out); err != nil {
return nil, err
}
return out, nil
}
+18
View File
@@ -0,0 +1,18 @@
package configserver
import "context"
// api_maintain.go — обслуживание: очистка старых версий.
// CleanupOldVersions удаляет версии compose/env и логи миграций старше days дней
// (текущие версии не трогаются).
func (a *API) CleanupOldVersions(ctx context.Context, days int) (*CleanupAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
body := map[string]int{"days": days}
out := &CleanupAck{}
if err := a.Sess.postJSON(ctx, "/api/cleanup_old_versions", body, out); err != nil {
return nil, err
}
return out, nil
}
+289
View File
@@ -0,0 +1,289 @@
package configserver
import (
"context"
"net/url"
"strconv"
"strings"
"time"
)
// api_observe.go — read-only операции (наблюдение и диагностика).
// WhoAmI — ответ GET /api/whoami.
type WhoAmI struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
}
// Capabilities — результат capability-probe: версия API и доступные группы
// JSON-эндпоинтов v1.1.0. Нужен, чтобы инструменты не «тихо» ломались на
// старых стендах, а возвращали внятное сообщение.
type Capabilities struct {
Version string `json:"version"`
Features map[string]bool `json:"features"`
}
// Health проверяет живость сервера (GET /health, без авторизации).
func (a *API) Health(ctx context.Context) (int, string, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
resp, err := a.Sess.getPublic(ctx, "/health")
if err != nil {
return 0, "", err
}
return resp.status, strings.TrimSpace(string(resp.body)), nil
}
// WhoAmI проверяет текущую авторизацию.
func (a *API) WhoAmI(ctx context.Context) (*WhoAmI, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &WhoAmI{}
if err := a.Sess.getJSON(ctx, "/api/whoami", nil, out); err != nil {
return nil, err
}
return out, nil
}
// Probe определяет версию API. Ключевой признак v1.1.0 — публичный JSON
// `/api/whoami` (в <=1.0.20 его нет, middleware отвечает 401).
func (a *API) Probe(ctx context.Context) (*Capabilities, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
resp, err := a.Sess.getPublic(ctx, "/api/whoami")
if err != nil {
return nil, err
}
caps := &Capabilities{Version: "legacy (<1.1.0)", Features: map[string]bool{}}
if resp.status == 200 && strings.Contains(resp.ctype, "json") {
caps.Version = "1.1.0"
for _, f := range []string{
"api_login", "api_whoami", "application_detail",
"set_compose", "set_env", "tasks_filter", "task_get", "update_front",
} {
caps.Features[f] = true
}
}
return caps, nil
}
// ListApplications возвращает список сервисов.
func (a *API) ListApplications(ctx context.Context, includePresence bool) ([]Application, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
q := url.Values{}
if includePresence {
q.Set("include_presence", "true")
}
var out []Application
if err := a.Sess.getJSON(ctx, "/api/applications", q, &out); err != nil {
return nil, err
}
return out, nil
}
// GetApplication возвращает детализацию сервиса.
func (a *API) GetApplication(ctx context.Context, id int) (*ApplicationDetail, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &ApplicationDetail{}
if err := a.Sess.getJSON(ctx, "/api/application/"+strconv.Itoa(id), nil, out); err != nil {
return nil, err
}
return out, nil
}
// GetConfig возвращает текущие compose/env сервиса (эндпоинт агента, публичный).
func (a *API) GetConfig(ctx context.Context, id int) (*RawConfig, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &RawConfig{}
if err := a.Sess.getJSON(ctx, "/api/get_config/"+strconv.Itoa(id), nil, out); err != nil {
return nil, err
}
return out, nil
}
// EnvFiles возвращает имена текущих env-файлов сервиса.
func (a *API) EnvFiles(ctx context.Context, id int) ([]string, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
var out []string
if err := a.Sess.getJSON(ctx, "/api/env_files/"+strconv.Itoa(id), nil, &out); err != nil {
return nil, err
}
return out, nil
}
// ComposeVersion возвращает конкретную версию compose.
func (a *API) ComposeVersion(ctx context.Context, versionID int) (*ComposeVersion, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &ComposeVersion{}
if err := a.Sess.getJSON(ctx, "/api/compose_version/"+strconv.Itoa(versionID), nil, out); err != nil {
return nil, err
}
return out, nil
}
// EnvVersion возвращает конкретную версию env-файла.
func (a *API) EnvVersion(ctx context.Context, versionID int) (*EnvVersion, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &EnvVersion{}
if err := a.Sess.getJSON(ctx, "/api/env_version/"+strconv.Itoa(versionID), nil, out); err != nil {
return nil, err
}
return out, nil
}
// EnvVersions возвращает все версии конкретного env-файла.
func (a *API) EnvVersions(ctx context.Context, appID int, filename string) ([]EnvVersion, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
path := "/api/env_versions/" + strconv.Itoa(appID) + "/" + url.PathEscape(filename)
var out []EnvVersion
if err := a.Sess.getJSON(ctx, path, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// TaskFilter — фильтры списка задач.
type TaskFilter struct {
HostIP string
Status string
TaskType string
Limit int
}
// ListTasks возвращает задачи с фильтрами (GET /api/tasks).
func (a *API) ListTasks(ctx context.Context, f TaskFilter) ([]Task, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
q := url.Values{}
if f.HostIP != "" {
q.Set("host_ip", f.HostIP)
}
if f.Status != "" {
q.Set("status", f.Status)
}
if f.TaskType != "" {
q.Set("task_type", f.TaskType)
}
if f.Limit > 0 {
q.Set("limit", strconv.Itoa(f.Limit))
}
var out []Task
if err := a.Sess.getJSON(ctx, "/api/tasks", q, &out); err != nil {
return nil, err
}
return out, nil
}
// GetTask возвращает задачу по task_id.
func (a *API) GetTask(ctx context.Context, taskID string) (*Task, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &Task{}
if err := a.Sess.getJSON(ctx, "/api/task/"+url.PathEscape(taskID), nil, out); err != nil {
return nil, err
}
return out, nil
}
// WaitTask опрашивает задачу до завершения (completed/failed) или до истечения
// task_poll_max_sec. Возвращает последнее известное состояние задачи; признак
// незавершённости — Status == "pending"/"in_progress". Loop агента
// последовательный, поэтому ожидание строго ограничено конфигом.
func (a *API) WaitTask(ctx context.Context, taskID string) (*Task, error) {
deadline := time.Now().Add(time.Duration(a.Srv.TaskPollMaxSec) * time.Second)
interval := time.Duration(a.Srv.TaskPollIntervalSec) * time.Second
if interval <= 0 {
interval = time.Second
}
for {
task, err := a.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
if task.Status == "completed" || task.Status == "failed" {
return task, nil
}
if !time.Now().Before(deadline) {
return task, nil
}
select {
case <-ctx.Done():
return task, nil
case <-time.After(interval):
}
}
}
// DeploymentFiles возвращает schema/manifest/prebuild_vars/ip_overrides.
func (a *API) DeploymentFiles(ctx context.Context) (*DeploymentFiles, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &DeploymentFiles{}
if err := a.Sess.getJSON(ctx, "/api/deployment/files", nil, out); err != nil {
return nil, err
}
return out, nil
}
// IPMatch возвращает диагностику сопоставления IP схемы и агентов.
func (a *API) IPMatch(ctx context.Context) (*IPMatch, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &IPMatch{}
if err := a.Sess.getJSON(ctx, "/api/deployment/ip_match", nil, out); err != nil {
return nil, err
}
return out, nil
}
// ReleaseJob возвращает статус фоновой загрузки релиза.
func (a *API) ReleaseJob(ctx context.Context) (*ReleaseJob, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &ReleaseJob{}
if err := a.Sess.getJSON(ctx, "/api/deployment/release_job", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// DeploymentTasks возвращает последние release-задачи (sync/start).
func (a *API) DeploymentTasks(ctx context.Context) ([]Task, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
var out []Task
if err := a.Sess.getJSON(ctx, "/api/deployment/tasks", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// Stats возвращает счётчики версий.
func (a *API) Stats(ctx context.Context) (*Stats, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &Stats{}
if err := a.Sess.getJSON(ctx, "/api/stats", nil, out); err != nil {
return nil, err
}
return out, nil
}
// ServiceMap возвращает карту серверов и сервисов.
func (a *API) ServiceMap(ctx context.Context) (*ServiceMap, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &ServiceMap{}
if err := a.Sess.getJSON(ctx, "/api/service_map", nil, out); err != nil {
return nil, err
}
return out, nil
}
+149
View File
@@ -0,0 +1,149 @@
package configserver
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
)
// api_operate.go — жизненный цикл сервиса и загрузка фронта.
// Deploy поднимает сервис (docker-compose up -d).
func (a *API) Deploy(ctx context.Context, appID int) (*TaskAck, error) {
return a.lifecycle(ctx, "deploy", appID)
}
// Restart перезапускает сервис (down + up -d).
func (a *API) Restart(ctx context.Context, appID int) (*TaskAck, error) {
return a.lifecycle(ctx, "restart", appID)
}
// Down останавливает сервис (docker-compose down).
func (a *API) Down(ctx context.Context, appID int) (*TaskAck, error) {
return a.lifecycle(ctx, "down", appID)
}
// Migrate запускает миграцию (docker-compose run migration).
func (a *API) Migrate(ctx context.Context, appID int) (*TaskAck, error) {
return a.lifecycle(ctx, "migrate", appID)
}
// lifecycle — общий вызов POST /api/<action>/<app_id>, возвращающий task_id.
func (a *API) lifecycle(ctx context.Context, action string, appID int) (*TaskAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &TaskAck{}
path := "/api/" + action + "/" + strconv.Itoa(appID)
if err := a.Sess.postJSON(ctx, path, nil, out); err != nil {
return nil, err
}
return out, nil
}
// UpdateFront загружает локальную сборку фронта и создаёт задачу её доставки
// агенту. Каталог обязан лежать внутри front_roots (jail, fail-closed).
func (a *API) UpdateFront(ctx context.Context, appID int, buildDir string) (*FrontAck, error) {
files, err := a.collectFrontFiles(buildDir)
if err != nil {
return nil, err
}
ctx, cancel := a.withUploadTimeout(ctx)
defer cancel()
raw, err := a.Sess.postMultipart(ctx, "/api/update_front/"+strconv.Itoa(appID), files)
if err != nil {
return nil, err
}
out := &FrontAck{}
if err := json.Unmarshal(raw, out); err != nil {
return nil, fmt.Errorf("разбор ответа update_front: %w", err)
}
return out, nil
}
// collectFrontFiles собирает файлы сборки, проверяя containment и лимиты.
func (a *API) collectFrontFiles(buildDir string) ([]UploadFile, error) {
if len(a.Srv.FrontRoots) == 0 {
return nil, ErrNoFrontRoots
}
root, err := resolveInsideRoots(buildDir, a.Srv.FrontRoots)
if err != nil {
return nil, err
}
var (
files []UploadFile
total int64
)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
// Симлинки и спецфайлы пропускаем: загружаем только обычные файлы.
if !d.Type().IsRegular() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
if total > a.Srv.MaxUploadBytes {
return validationErrorf("размер сборки превышает лимит %d байт", a.Srv.MaxUploadBytes)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
files = append(files, UploadFile{Name: filepath.ToSlash(rel), Data: data})
return nil
})
if err != nil {
return nil, fmt.Errorf("чтение сборки %s: %w", buildDir, err)
}
if len(files) == 0 {
return nil, validationErrorf("в каталоге %s нет файлов", buildDir)
}
return files, nil
}
// resolveInsideRoots приводит путь к абсолютному и проверяет, что после
// разрешения симлинков он лежит внутри одного из разрешённых корней.
func resolveInsideRoots(target string, roots []string) (string, error) {
abs, err := filepath.Abs(target)
if err != nil {
return "", fmt.Errorf("некорректный путь %q: %w", target, err)
}
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
return "", validationErrorf("путь %q недоступен: %v", target, err)
}
resolved = filepath.Clean(resolved)
for _, r := range roots {
rootAbs, err := filepath.Abs(r)
if err != nil {
continue
}
// Симлинки в самом корне не должны «съесть» проверку — разрешаем, если можем.
if rootResolved, err := filepath.EvalSymlinks(rootAbs); err == nil {
rootAbs = rootResolved
}
rootAbs = filepath.Clean(rootAbs)
if resolved == rootAbs || strings.HasPrefix(resolved, rootAbs+string(os.PathSeparator)) {
return resolved, nil
}
}
return "", validationErrorf("путь %q вне разрешённых front_roots", target)
}
+81
View File
@@ -0,0 +1,81 @@
package configserver
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestResolveInsideRoots(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "build")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "index.html"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoots(sub, []string{root}); err != nil {
t.Fatalf("каталог внутри root должен быть разрешён: %v", err)
}
outside := t.TempDir()
if _, err := resolveInsideRoots(outside, []string{root}); err == nil {
t.Fatal("каталог вне root должен быть отклонён")
}
}
func TestResolveInsideRootsSymlinkEscape(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "escape")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("симлинки недоступны: %v", err)
}
if _, err := resolveInsideRoots(link, []string{root}); err == nil {
t.Fatal("symlink-побег из root должен быть отклонён")
}
}
func TestCollectFrontFilesNoRoots(t *testing.T) {
api := &API{Srv: Server{}}
_, err := api.collectFrontFiles("/tmp")
if !errors.Is(err, ErrNoFrontRoots) {
t.Fatalf("ожидалась ErrNoFrontRoots, получено: %v", err)
}
}
func TestCollectFrontFiles(t *testing.T) {
root := t.TempDir()
build := filepath.Join(root, "dist")
if err := os.MkdirAll(filepath.Join(build, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(build, "index.html"), []byte("<html/>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(build, "assets", "app.js"), []byte("js"), 0o644); err != nil {
t.Fatal(err)
}
api := &API{Srv: Server{FrontRoots: []string{root}, MaxUploadBytes: DefaultMaxUploadBytes}}
files, err := api.collectFrontFiles(build)
if err != nil {
t.Fatalf("collectFrontFiles: %v", err)
}
if len(files) != 2 {
t.Fatalf("файлов = %d, ожидалось 2", len(files))
}
names := map[string]bool{}
for _, f := range files {
names[f.Name] = true
}
if !names["index.html"] || !names["assets/app.js"] {
t.Fatalf("неожиданные относительные пути: %+v", names)
}
}
+97
View File
@@ -0,0 +1,97 @@
package configserver
import (
"context"
"encoding/json"
)
// api_release.go — страница «Развёртывание»: файлы схемы, seed/fill, релиз.
// DeploymentUpdate — частичное обновление файлов развёртывания. nil-поля не
// отправляются (сервер не перезаписывает их пустыми значениями).
type DeploymentUpdate struct {
Schema *string
Manifest *string
PrebuildVars *string
IPOverrides *string
}
// SaveDeploymentFiles сохраняет schema/manifest/prebuild_vars/ip_overrides.
func (a *API) SaveDeploymentFiles(ctx context.Context, upd DeploymentUpdate) (*StatusAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
body := map[string]string{}
if upd.Schema != nil {
body["schema"] = *upd.Schema
}
if upd.Manifest != nil {
body["manifest"] = *upd.Manifest
}
if upd.PrebuildVars != nil {
body["prebuild_vars"] = *upd.PrebuildVars
}
if upd.IPOverrides != nil {
body["ip_overrides"] = *upd.IPOverrides
}
out := &StatusAck{}
if err := a.Sess.postJSON(ctx, "/api/deployment/files", body, out); err != nil {
return nil, err
}
return out, nil
}
// SeedHosts создаёт хосты из schema.json.
func (a *API) SeedHosts(ctx context.Context) (*SeedHostsAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &SeedHostsAck{}
if err := a.Sess.postJSON(ctx, "/api/deployment/seed_hosts", nil, out); err != nil {
return nil, err
}
return out, nil
}
// FillVars заполняет prebuild_vars из схемы (ответ — произвольный JSON).
func (a *API) FillVars(ctx context.Context) (json.RawMessage, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
var out json.RawMessage
if err := a.Sess.postJSON(ctx, "/api/deployment/fill_vars", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// DownloadRelease запускает загрузку релиза. При load_images=true сервер
// отвечает сразу (фоновая задача), прогресс читается через ReleaseJob.
func (a *API) DownloadRelease(ctx context.Context, token string, loadImages bool) (*DownloadReleaseAck, error) {
if token == "" {
token = a.Srv.GitlabToken
}
body := map[string]any{"load_images": loadImages}
if token != "" {
body["gitlab_token"] = token
}
// Синхронная выгрузка (load_images=false) может быть долгой.
ctx, cancel := a.withUploadTimeout(ctx)
defer cancel()
out := &DownloadReleaseAck{}
if err := a.Sess.postJSON(ctx, "/api/deployment/download_release", body, out); err != nil {
return nil, err
}
return out, nil
}
// StartServices создаёт задачи запуска сервисов для хостов из схемы.
func (a *API) StartServices(ctx context.Context) (*StartServicesAck, error) {
ctx, cancel := a.withTimeout(ctx)
defer cancel()
out := &StartServicesAck{}
if err := a.Sess.postJSON(ctx, "/api/deployment/start_services", nil, out); err != nil {
return nil, err
}
return out, nil
}
+391
View File
@@ -0,0 +1,391 @@
// Package configserver — доменный слой MCP-модуля omnichannel-mcp.
//
// Здесь нет зависимостей от MCP: только разбор конфига, авторизованные
// HTTP-сессии к API config_server и типизированные вызовы его эндпоинтов.
// MCP-слой (internal/tools) — тонкая обёртка, которая парсит аргументы,
// дёргает этот пакет и форматирует результат.
package configserver
import (
"encoding/json"
"fmt"
"net/url"
"os"
"regexp"
"sort"
"strings"
"git.totmin.ru/en2zmax/forge-toolkit"
)
// Значения по умолчанию для лимитов сервера. Все они задаются в config.json,
// но модуль должен работать и с минимальным конфигом (только base_url).
const (
DefaultTimeoutSec = 30
DefaultUploadTimeoutSec = 300
DefaultTaskPollMaxSec = 120
DefaultTaskPollIntervalSec = 2
DefaultMaxOutputBytes = 100_000
DefaultMaxUploadBytes int64 = 512 << 20 // 512 MiB
DefaultMaxConcurrentMutations = 1
)
// Server — одно подключение к config_server (стенд/контур).
type Server struct {
Alias string `json:"alias"`
// BaseURL — адрес API, например http://10.101.60.3:5005.
BaseURL string `json:"base_url"`
// Username/Password — учётная запись с правом записи (используется
// изменяющими инструментами).
Username string `json:"username"`
Password string `json:"password"`
// ReadonlyUsername/ReadonlyPassword — необязательная учётная запись только
// для чтения. Если задана, read-инструменты используют её (least-privilege
// на нашей стороне; см. ARCHITECTURE.md §9.5).
ReadonlyUsername string `json:"readonly_username"`
ReadonlyPassword string `json:"readonly_password"`
// GitlabToken — токен для /api/deployment/download_release. Секрет: держим
// в env/локальном конфиге, никогда не логируем.
GitlabToken string `json:"gitlab_token"`
// InsecureSkipVerify отключает проверку TLS-сертификата (для стендов с
// самоподписанными сертификатами).
InsecureSkipVerify bool `json:"insecure_skip_verify"`
TimeoutSec int `json:"timeout_sec"`
UploadTimeoutSec int `json:"upload_timeout_sec"`
TaskPollMaxSec int `json:"task_poll_max_sec"`
TaskPollIntervalSec int `json:"task_poll_interval_sec"`
MaxOutputBytes int `json:"max_output_bytes"`
MaxUploadBytes int64 `json:"max_upload_bytes"`
MaxConcurrentMutations int `json:"max_concurrent_mutations"`
// FrontRoots — разрешённые каталоги локальных сборок фронта для
// update_front (jail). Пусто — загрузка фронта запрещена (fail-closed).
FrontRoots []string `json:"front_roots"`
}
// Config — корневой конфиг модуля (config.json или per-agent omnichannel-mcp.json).
type Config struct {
Servers []Server `json:"servers"`
Default string `json:"default"`
ReadOnly bool `json:"read_only"`
// AllowHosts — необязательный allowlist хостов. Если задан, base_url любого
// сервера обязан быть на одном из этих хостов (защита от опечаток/SSRF).
// Пусто — доверяем хостам из самих серверов.
AllowHosts []string `json:"allow_hosts"`
}
// rawConfig — форма для строгого разбора: позволяет отличить «read_only не
// задан» (тогда безопасный дефолт true) от явного false.
type rawConfig struct {
Servers []Server `json:"servers"`
Default string `json:"default"`
ReadOnly *bool `json:"read_only"`
AllowHosts []string `json:"allow_hosts"`
// Single-server shorthand: если servers пуст, а base_url задан — считаем это
// одним сервером с алиасом "default".
BaseURL string `json:"base_url"`
Username string `json:"username"`
Password string `json:"password"`
}
var envVarRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// ParseConfig разбирает конфиг: раскрывает ${VAR} из окружения, применяет
// дефолты и валидирует. Fail-closed: незаполненная переменная, битый адрес или
// отсутствие серверов — ошибка, а не «подозрительный дефолт».
func ParseConfig(data []byte) (*Config, error) {
if unresolved := unresolvedVars(data); len(unresolved) > 0 {
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
}
return decodeConfig(toolkit.Expand(data))
}
// decodeConfig разбирает уже раскрытый JSON (без ${VAR}).
func decodeConfig(data []byte) (*Config, error) {
var raw rawConfig
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("разбор config.json: %w", err)
}
cfg := &Config{
Servers: raw.Servers,
Default: strings.TrimSpace(raw.Default),
ReadOnly: true,
AllowHosts: raw.AllowHosts,
}
if raw.ReadOnly != nil {
cfg.ReadOnly = *raw.ReadOnly
}
if len(cfg.Servers) == 0 && strings.TrimSpace(raw.BaseURL) != "" {
cfg.Servers = []Server{{Alias: "default", BaseURL: raw.BaseURL, Username: raw.Username, Password: raw.Password}}
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// LocalConfigSuffix — суффикс локального файла с секретами (gitignored).
const LocalConfigSuffix = ".local"
// LoadFile загружает конфиг из основного файла, накладывая поверх соседний
// <name>.local.json (реальные секреты; в репозитории — только шаблон).
func LoadFile(path string) (*Config, error) {
mainData, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if unresolved := unresolvedVars(mainData); len(unresolved) > 0 {
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
}
expanded := toolkit.Expand(mainData)
if localPath := localConfigPath(path); localPath != "" {
if localData, err := os.ReadFile(localPath); err == nil {
if unresolved := unresolvedVars(localData); len(unresolved) > 0 {
return nil, fmt.Errorf("не заданы переменные окружения (в %s): %s", localPath, strings.Join(unresolved, ", "))
}
if expanded, err = mergeConfigJSON(expanded, toolkit.Expand(localData)); err != nil {
return nil, fmt.Errorf("слияние %s: %w", localPath, err)
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("чтение %s: %w", localPath, err)
}
}
return decodeConfig(expanded)
}
// localConfigPath возвращает путь <dir>/<name>.local.json для основного файла.
func localConfigPath(path string) string {
if strings.HasSuffix(path, ".json") {
return strings.TrimSuffix(path, ".json") + LocalConfigSuffix + ".json"
}
return path + LocalConfigSuffix
}
// mergeConfigJSON накладывает over поверх base: объекты сливаются рекурсивно,
// массивы servers — по alias (локальный сервер дополняет/перезаписывает
// одноимённый), прочие значения over перезаписывают base.
func mergeConfigJSON(base, over []byte) ([]byte, error) {
var baseMap, overMap map[string]any
if err := json.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
if err := json.Unmarshal(over, &overMap); err != nil {
return nil, err
}
merged := mergeMaps(baseMap, overMap)
return json.Marshal(merged)
}
// mergeMaps рекурсивно сливает карты; список servers обрабатывается отдельно.
func mergeMaps(base, over map[string]any) map[string]any {
out := make(map[string]any, len(base)+len(over))
for k, v := range base {
out[k] = v
}
for k, v := range over {
if k == "servers" {
out[k] = mergeServers(out[k], v)
continue
}
if baseChild, ok := out[k].(map[string]any); ok {
if overChild, ok := v.(map[string]any); ok {
out[k] = mergeMaps(baseChild, overChild)
continue
}
}
out[k] = v
}
return out
}
// mergeServers сливает массивы серверов по полю alias.
func mergeServers(base, over any) []any {
baseList, _ := base.([]any)
overList, _ := over.([]any)
index := map[string]int{}
var result []any
for _, item := range baseList {
m, _ := item.(map[string]any)
alias, _ := m["alias"].(string)
index[alias] = len(result)
result = append(result, m)
}
for _, item := range overList {
m, _ := item.(map[string]any)
alias, _ := m["alias"].(string)
if pos, ok := index[alias]; ok {
if baseServer, ok := result[pos].(map[string]any); ok {
result[pos] = mergeMaps(baseServer, m)
continue
}
}
index[alias] = len(result)
result = append(result, m)
}
return result
}
// unresolvedVars возвращает имена переменных ${VAR}, которых нет в окружении.
func unresolvedVars(data []byte) []string {
seen := map[string]bool{}
var out []string
for _, m := range envVarRe.FindAllSubmatch(data, -1) {
name := string(m[1])
if seen[name] {
continue
}
seen[name] = true
if os.Getenv(name) == "" {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// normalize применяет дефолты и валидирует конфиг.
func (c *Config) normalize() error {
if len(c.Servers) == 0 {
return fmt.Errorf("не задано ни одного сервера (servers или base_url)")
}
aliases := map[string]bool{}
for i := range c.Servers {
s := &c.Servers[i]
if strings.TrimSpace(s.Alias) == "" {
return fmt.Errorf("servers[%d]: не задан alias", i)
}
if aliases[s.Alias] {
return fmt.Errorf("дублирующийся alias %q", s.Alias)
}
aliases[s.Alias] = true
if err := c.validateServer(s); err != nil {
return fmt.Errorf("сервер %q: %w", s.Alias, err)
}
}
// default: явный или первый сервер.
if c.Default == "" {
c.Default = c.Servers[0].Alias
}
if !aliases[c.Default] {
return fmt.Errorf("default %q не найден среди серверов (%s)", c.Default, strings.Join(c.ServerNames(), ", "))
}
return nil
}
// validateServer проверяет адрес, allowlist и заполняет лимиты дефолтами.
func (c *Config) validateServer(s *Server) error {
u, err := url.Parse(strings.TrimSpace(s.BaseURL))
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("некорректный base_url %q", s.BaseURL)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("base_url должен быть http/https, получено %q", u.Scheme)
}
if len(c.AllowHosts) > 0 && !containsFold(c.AllowHosts, u.Hostname()) {
return fmt.Errorf("хост %q не в allow_hosts (%s)", u.Hostname(), strings.Join(c.AllowHosts, ", "))
}
s.BaseURL = strings.TrimRight(s.BaseURL, "/")
s.TimeoutSec = orDefault(s.TimeoutSec, DefaultTimeoutSec)
s.UploadTimeoutSec = orDefault(s.UploadTimeoutSec, DefaultUploadTimeoutSec)
s.TaskPollMaxSec = orDefault(s.TaskPollMaxSec, DefaultTaskPollMaxSec)
s.TaskPollIntervalSec = orDefault(s.TaskPollIntervalSec, DefaultTaskPollIntervalSec)
s.MaxOutputBytes = orDefault(s.MaxOutputBytes, DefaultMaxOutputBytes)
s.MaxConcurrentMutations = orDefault(s.MaxConcurrentMutations, DefaultMaxConcurrentMutations)
if s.MaxUploadBytes <= 0 {
s.MaxUploadBytes = DefaultMaxUploadBytes
}
return nil
}
// Server возвращает сервер по алиасу (пустой — default).
func (c *Config) Server(alias string) (*Server, error) {
if alias == "" {
alias = c.Default
}
for i := range c.Servers {
if c.Servers[i].Alias == alias {
return &c.Servers[i], nil
}
}
return nil, fmt.Errorf("неизвестный сервер %q; настроены: %s (default: %s)",
alias, strings.Join(c.ServerNames(), ", "), c.Default)
}
// ServerNames возвращает список алиасов (для сообщений об ошибках).
func (c *Config) ServerNames() []string {
out := make([]string, 0, len(c.Servers))
for _, s := range c.Servers {
out = append(out, s.Alias)
}
return out
}
// Redacted возвращает копию конфига с замаскированными секретами — для
// `--check-config` и диагностики. Пароли/токены не покидают процесс.
func (c *Config) Redacted() map[string]any {
servers := make([]map[string]any, 0, len(c.Servers))
for _, s := range c.Servers {
servers = append(servers, map[string]any{
"alias": s.Alias,
"base_url": s.BaseURL,
"username": s.Username,
"password": mask(s.Password),
"readonly_username": s.ReadonlyUsername,
"readonly_password": mask(s.ReadonlyPassword),
"gitlab_token": mask(s.GitlabToken),
"insecure_skip_verify": s.InsecureSkipVerify,
"timeout_sec": s.TimeoutSec,
"upload_timeout_sec": s.UploadTimeoutSec,
"task_poll_max_sec": s.TaskPollMaxSec,
"task_poll_interval_sec": s.TaskPollIntervalSec,
"max_output_bytes": s.MaxOutputBytes,
"max_upload_bytes": s.MaxUploadBytes,
"max_concurrent_mutations": s.MaxConcurrentMutations,
"front_roots": s.FrontRoots,
})
}
return map[string]any{
"servers": servers,
"default": c.Default,
"read_only": c.ReadOnly,
"allow_hosts": c.AllowHosts,
}
}
func orDefault(v, def int) int {
if v <= 0 {
return def
}
return v
}
func mask(secret string) string {
if secret == "" {
return ""
}
return "***"
}
func containsFold(list []string, value string) bool {
for _, item := range list {
if strings.EqualFold(item, value) {
return true
}
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
package configserver
import (
"os"
"path/filepath"
"testing"
)
func TestParseConfigDefaults(t *testing.T) {
cfg, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005","username":"u","password":"p"}]}`))
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
if !cfg.ReadOnly {
t.Fatalf("read_only должен по умолчанию быть true (fail-safe)")
}
if cfg.Default != "a" {
t.Fatalf("default = %q, ожидалось a", cfg.Default)
}
srv, err := cfg.Server("")
if err != nil {
t.Fatalf("Server: %v", err)
}
if srv.TimeoutSec != DefaultTimeoutSec || srv.MaxOutputBytes != DefaultMaxOutputBytes {
t.Fatalf("дефолты лимитов не применены: %+v", srv)
}
}
func TestSingleServerShorthand(t *testing.T) {
cfg, err := ParseConfig([]byte(`{"base_url":"http://h:5005","username":"u","password":"p"}`))
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
if len(cfg.Servers) != 1 || cfg.Servers[0].Alias != "default" {
t.Fatalf("shorthand не сработал: %+v", cfg.Servers)
}
if cfg.Default != "default" {
t.Fatalf("default = %q", cfg.Default)
}
}
func TestUnresolvedVarFails(t *testing.T) {
t.Setenv("OMNI_PRESENT", "x")
_ = os.Unsetenv("OMNI_MISSING")
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"${OMNI_MISSING}","username":"u","password":"p"}]}`))
if err == nil {
t.Fatal("ожидалась ошибка о незаданной переменной")
}
}
func TestUnknownFieldFails(t *testing.T) {
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005"}],"typo_field":1}`))
if err == nil {
t.Fatal("ожидалась ошибка строгого разбора неизвестного поля")
}
}
func TestHostAllowlist(t *testing.T) {
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://evil:5005"}],"allow_hosts":["good"]}`))
if err == nil {
t.Fatal("ожидалась ошибка: хост вне allow_hosts")
}
}
func TestServerUnknownAlias(t *testing.T) {
cfg, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005"}]}`))
if err != nil {
t.Fatal(err)
}
if _, err := cfg.Server("nope"); err == nil {
t.Fatal("ожидалась ошибка неизвестного алиаса")
}
}
func TestLoadFileMergesLocal(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "config.json")
local := filepath.Join(dir, "config.local.json")
if err := os.WriteFile(main, []byte(`{
"servers":[{"alias":"prod","base_url":"http://example:5005","username":"u","password":"p"}],
"read_only": true
}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(local, []byte(`{
"servers":[{"alias":"prod","base_url":"http://127.0.0.1:5005","password":"secret"}],
"read_only": false
}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadFile(main)
if err != nil {
t.Fatalf("LoadFile: %v", err)
}
if cfg.ReadOnly {
t.Fatal("read_only из local не применён")
}
srv := cfg.Servers[0]
if srv.BaseURL != "http://127.0.0.1:5005" {
t.Fatalf("base_url не переопределён: %q", srv.BaseURL)
}
if srv.Username != "u" {
t.Fatalf("username из main потерян: %q", srv.Username)
}
if srv.Password != "secret" {
t.Fatalf("password из local не применён: %q", srv.Password)
}
}
+46
View File
@@ -0,0 +1,46 @@
package configserver
import (
"errors"
"fmt"
)
// Сентинельные ошибки домена. MCP-слой различает их, чтобы выбрать между
// recoverable-ответом (`errorResult`) и инфраструктурной Go-ошибкой.
var (
// ErrNoConfig — не найден ни один конфиг (ни `-config`, ни OMNI_CONFIG_DIR).
ErrNoConfig = errors.New("конфиг не найден: задайте -config или per-agent omnichannel-mcp.json")
// ErrReadOnly — модуль в режиме только-чтение, мутация запрещена.
ErrReadOnly = errors.New("модуль в режиме read_only: изменяющие инструменты отключены")
// ErrConfirmRequired — разрушительная операция без confirm=\"true\".
ErrConfirmRequired = errors.New("операция требует confirm=\"true\"")
// ErrNoFrontRoots — не заданы front_roots, загрузка фронта запрещена.
ErrNoFrontRoots = errors.New("загрузка фронта выключена: не заданы front_roots в конфиге")
)
// APIError — доменная ошибка API config_server (4xx/5xx с телом {"error": ...}).
// Такие ошибки показываются модели (recoverable), чтобы она могла исправиться.
type APIError struct {
Status int
Message string
}
func (e *APIError) Error() string {
if e.Message == "" {
return fmt.Sprintf("config_server вернул HTTP %d", e.Status)
}
return fmt.Sprintf("config_server (HTTP %d): %s", e.Status, e.Message)
}
// ValidationError — доменная ошибка модуля (невалидный путь, лимит, политика).
// Тоже recoverable: модель может исправить аргументы.
type ValidationError struct {
Msg string
}
func (e *ValidationError) Error() string { return e.Msg }
// validationErrorf создаёт ValidationError с форматированием.
func validationErrorf(format string, args ...any) error {
return &ValidationError{Msg: fmt.Sprintf(format, args...)}
}
+209
View File
@@ -0,0 +1,209 @@
package configserver
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
)
// tenantFileName — имя файла конфига внутри каталога OMNI_CONFIG_DIR
// (например, при постраничной/per-call передаче конфига).
const tenantFileName = "omnichannel-mcp.json"
// Manager — одиночный потокобезопасный диспетчер процесса (ARCHITECTURE.md §2).
// Кэширует per-путь загрузчики конфига (live-reload по контент-хэшу), сессии
// (отдельно rw/ro) и семафоры на мутации. Конкурентный доступ агентов безопасен.
type Manager struct {
staticPath string
mu sync.Mutex
loaders map[string]*configreload.Loader[*Config]
sessions map[string]*Session
sems map[string]chan struct{}
}
// NewManager создаёт Manager. staticPath — путь из `-config` (основной режим);
// пусто — путь ищется в OMNI_CONFIG_DIR.
func NewManager(staticPath string) *Manager {
return &Manager{
staticPath: staticPath,
loaders: map[string]*configreload.Loader[*Config]{},
sessions: map[string]*Session{},
sems: map[string]chan struct{}{},
}
}
// ResolvePath выбирает путь конфига по приоритету: явный путь вызова →
// каталог из окружения (OMNI_CONFIG_DIR, затем legacy FORGE_TENANT_CONFIG) →
// статический `-config`. Если переменная задаёт каталог, в нём берётся
// tenantFileName.
func (m *Manager) ResolvePath(tenantPath string) string {
if tenantPath != "" {
return tenantPath
}
for _, env := range []string{"OMNI_CONFIG_DIR", "FORGE_TENANT_CONFIG"} {
if dir := os.Getenv(env); dir != "" {
return filepath.Join(dir, tenantFileName)
}
}
return m.staticPath
}
// Config загружает конфиг (live-reload) и возвращает его вместе с путём.
// Отсутствие файла — fail-closed (ErrNoConfig); битый файл с last-good —
// работаем со старым значением и логируем.
func (m *Manager) Config(tenantPath string) (*Config, string, error) {
path := m.ResolvePath(tenantPath)
if path == "" {
return nil, "", ErrNoConfig
}
loader := m.loaderFor(path)
cfg, err := loader.Get()
if err != nil {
if errors.Is(err, configreload.ErrNotFound) {
return nil, path, fmt.Errorf("%w (файл %s)", ErrNoConfig, path)
}
if cfg == nil {
return nil, path, err
}
// last-good: продолжаем на прежнем конфиге, но сигналим в лог.
slog.Warn("конфиг изменён к невалидному, работаем на последнем рабочем", "path", path, "error", err)
}
return cfg, path, nil
}
// Open собирает фасад API для вызова: резолвит конфиг и сервер, проверяет
// read_only, поднимает сессию и (для мутаций) берёт семафор конкурентности.
// Close обязателен.
func (m *Manager) Open(ctx context.Context, tenantPath, alias string, write bool) (*API, error) {
cfg, path, err := m.Config(tenantPath)
if err != nil {
return nil, err
}
srv, err := cfg.Server(alias)
if err != nil {
return nil, err
}
if write && cfg.ReadOnly {
return nil, ErrReadOnly
}
// Read-инструменты используют read-only креды, если они заданы.
sess, err := m.session(path, srv, !write)
if err != nil {
return nil, err
}
api := &API{
CfgPath: path,
Srv: *srv,
Config: cfg,
Sess: sess,
Write: write,
}
if write {
sem := m.semaphore(path, srv.Alias, srv.MaxConcurrentMutations)
select {
case sem <- struct{}{}:
api.sem, api.held = sem, true
case <-ctx.Done():
return nil, ctx.Err()
}
}
return api, nil
}
// loaderFor возвращает (и кэширует) live-reload загрузчик по пути.
func (m *Manager) loaderFor(path string) *configreload.Loader[*Config] {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.loaders[path]; ok {
return l
}
l := configreload.New(path, func([]byte) (*Config, error) { return LoadFile(path) })
m.loaders[path] = l
return l
}
// session возвращает (и кэширует) сессию по (путь, алиас, rw/ro).
func (m *Manager) session(path string, srv *Server, readonly bool) (*Session, error) {
key := path + "|" + srv.Alias + "|" + strconv.FormatBool(readonly)
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
return s, nil
}
s, err := NewSession(srv, readonly)
if err != nil {
return nil, err
}
m.sessions[key] = s
return s, nil
}
// semaphore возвращает (и кэширует) семафор конкурентных мутаций на сервер.
func (m *Manager) semaphore(path, alias string, n int) chan struct{} {
if n < 1 {
n = 1
}
key := path + "|" + alias
m.mu.Lock()
defer m.mu.Unlock()
ch, ok := m.sems[key]
if !ok || cap(ch) != n {
ch = make(chan struct{}, n)
m.sems[key] = ch
}
return ch
}
// Close закрывает все сессии (graceful shutdown).
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
m.sessions = nil
m.sems = nil
}
// API — фасад одного вызова: конфиг сервера + авторизованная сессия + лимиты.
// Методы API (см. api_*.go) соответствуют эндпоинтам config_server v1.1.0.
type API struct {
CfgPath string
Srv Server
Config *Config
Sess *Session
Write bool
sem chan struct{}
held bool
}
// Close освобождает семафор мутаций (если он был взят). Идемпотентен.
func (a *API) Close() {
if a.held {
<-a.sem
a.held = false
}
}
// Alias возвращает алиас сервера (для сообщений).
func (a *API) Alias() string { return a.Srv.Alias }
// withTimeout накладывает таймаут сервера на вызов.
func (a *API) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, time.Duration(a.Srv.TimeoutSec)*time.Second)
}
// withUploadTimeout — увеличенный таймаут для загрузки фронта.
func (a *API) withUploadTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, time.Duration(a.Srv.UploadTimeoutSec)*time.Second)
}
+216
View File
@@ -0,0 +1,216 @@
package configserver
import "encoding/json"
// Типизированные модели ответов config_server v1.1.0. Используются и для
// разбора, и для отдачи модели — так схема ответа остаётся явной и стабильной.
// Application — краткая запись сервиса (GET /api/applications).
type Application struct {
ID int `json:"id"`
Name string `json:"name"`
HostIP string `json:"host_ip"`
Hostname string `json:"hostname"`
Path string `json:"path"`
Status string `json:"status"`
Active bool `json:"active"`
IsFront bool `json:"is_front"`
BuildFolder string `json:"build_folder"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ComposeVersionRef — ссылка на версию compose в детализации приложения.
type ComposeVersionRef struct {
ID int `json:"id"`
Version int `json:"version"`
IsCurrent bool `json:"is_current"`
CreatedAt string `json:"created_at"`
}
// MigrationLog — запись журнала миграций приложения.
type MigrationLog struct {
ID int `json:"id"`
Command string `json:"command"`
Status string `json:"status"`
Output string `json:"output"`
CreatedAt string `json:"created_at"`
}
// ApplicationDetail — расширенная детализация (GET /api/application/<id>).
type ApplicationDetail struct {
Application
CurrentCompose string `json:"current_compose"`
ComposeVersions []ComposeVersionRef `json:"compose_versions"`
EnvFiles []string `json:"env_files"`
CurrentEnvFiles map[string]string `json:"current_env_files"`
MigrationLogs []MigrationLog `json:"migration_logs"`
}
// RawConfig — текущие compose/env приложения (GET /api/get_config/<id>).
type RawConfig struct {
Compose string `json:"compose"`
Env string `json:"env"`
}
// ComposeVersion — конкретная версия compose (GET /api/compose_version/<id>).
type ComposeVersion struct {
ID int `json:"id"`
Version int `json:"version"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
IsCurrent bool `json:"is_current"`
}
// EnvVersion — конкретная версия env-файла (GET /api/env_version/<id>).
type EnvVersion struct {
ID int `json:"id"`
Filename string `json:"filename"`
Version int `json:"version"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
IsCurrent bool `json:"is_current"`
}
// Task — задача агента (GET /api/tasks, /api/task/<id>, /api/deployment/tasks).
type Task struct {
TaskID string `json:"task_id"`
ApplicationID int `json:"application_id"`
ApplicationName string `json:"application_name"`
TaskType string `json:"task_type"`
Command string `json:"command"`
Status string `json:"status"`
HostIP string `json:"host_ip"`
Payload json.RawMessage `json:"payload"`
Output string `json:"output"`
CreatedAt string `json:"created_at"`
CompletedAt string `json:"completed_at"`
}
// Stats — счётчики версий (GET /api/stats).
type Stats struct {
ComposeVersions int `json:"compose_versions"`
EnvVersions int `json:"env_versions"`
MigrationLogs int `json:"migration_logs"`
}
// ServiceInfo — сервис в карте серверов.
type ServiceInfo struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Status string `json:"status"`
Active bool `json:"active"`
UpdatedAt string `json:"updated_at"`
}
// ServerNode — хост и его сервисы в карте (GET /api/service_map).
type ServerNode struct {
HostIP string `json:"host_ip"`
Hostname string `json:"hostname"`
TotalServices int `json:"total_services"`
ActiveServices int `json:"active_services"`
Services []ServiceInfo `json:"services"`
}
// ServiceMap — ответ GET /api/service_map.
type ServiceMap struct {
Servers []ServerNode `json:"servers"`
ServersCount int `json:"servers_count"`
ServicesCount int `json:"services_count"`
}
// DeploymentFiles — schema/manifest/prebuild_vars/ip_overrides.
type DeploymentFiles struct {
Schema string `json:"schema"`
Manifest string `json:"manifest"`
PrebuildVars string `json:"prebuild_vars"`
IPOverrides string `json:"ip_overrides"`
}
// ReleaseJob — статус фоновой загрузки релиза (GET /api/deployment/release_job).
type ReleaseJob struct {
Status string `json:"status"`
Phase string `json:"phase"`
ImagesPulled int `json:"images_pulled"`
ImagesTotal int `json:"images_total"`
Message string `json:"message"`
Output string `json:"output"`
Stderr string `json:"stderr"`
UpdatedAt string `json:"updated_at"`
}
// IPMatch — диагностика сопоставления IP схемы и агентов.
type IPMatch struct {
SchemaIPs []string `json:"schema_ips"`
AgentIPsInDB []string `json:"agent_ips_in_db"`
IPOverrides map[string]string `json:"ip_overrides"`
MatchedPairs map[string]string `json:"matched_pairs"`
UnmatchedSchemaIPs []string `json:"unmatched_schema_ips"`
}
// TaskAck — ответ операций жизненного цикла (deploy/restart/down/migrate/update_config).
type TaskAck struct {
Status string `json:"status"`
AppID int `json:"app_id"`
AppName string `json:"app_name"`
HostIP string `json:"host_ip"`
TaskID string `json:"task_id"`
LogID int `json:"log_id"`
Message string `json:"message"`
}
// ConfigAck — ответ установки compose/env.
type ConfigAck struct {
Status string `json:"status"`
AppID int `json:"app_id"`
Version int `json:"version"`
Filename string `json:"filename"`
}
// StatusAck — простой ответ {"status": "..."} c опциональным сообщением.
type StatusAck struct {
Status string `json:"status"`
Message string `json:"message"`
}
// FrontAck — ответ update_front.
type FrontAck struct {
Status string `json:"status"`
AppID int `json:"app_id"`
BuildFolder string `json:"build_folder"`
TaskID string `json:"task_id"`
Message string `json:"message"`
}
// CleanupAck — ответ cleanup_old_versions.
type CleanupAck struct {
Status string `json:"status"`
DeletedComposeVersions int `json:"deleted_compose_versions"`
DeletedEnvVersions int `json:"deleted_env_versions"`
DeletedMigrationLogs int `json:"deleted_migration_logs"`
Message string `json:"message"`
}
// SeedHostsAck — ответ seed_hosts.
type SeedHostsAck struct {
Status string `json:"status"`
Created int `json:"created"`
Updated int `json:"updated"`
IPs []string `json:"ips"`
}
// DownloadReleaseAck — ответ download_release (синхронный и фоновый).
type DownloadReleaseAck struct {
Status string `json:"status"`
LoadImages bool `json:"load_images"`
Message string `json:"message"`
DownloadedServices []string `json:"downloaded_services"`
SyncTaskIDs []string `json:"sync_task_ids"`
}
// StartServicesAck — ответ start_services.
type StartServicesAck struct {
Status string `json:"status"`
StartTaskIDs []string `json:"start_task_ids"`
}
+354
View File
@@ -0,0 +1,354 @@
package configserver
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"time"
)
// maxResponseBytes — жёсткий предохранитель на размер тела ответа (защита от
// OOM). Доменные ответы (compose/env/schema) заметно меньше.
const maxResponseBytes = 16 << 20 // 16 MiB
// Session — авторизованная HTTP-сессия к одному config_server.
//
// Авторизация устроена как Flask-сессия: login кладёт подписанную cookie, она
// хранится в cookiejar и автоматически прикладывается к запросам. Сессия
// потокобезопасна: login выполняется один раз (single-flight), при 401 cookie
// пересоздаётся и запрос повторяется ровно один раз.
type Session struct {
baseURL string
username string
password string
label string // alias (+ "(ro)") для сообщений об ошибках
client *http.Client
mu sync.Mutex
loggedIn bool
}
// response — низкоуровневый ответ (тело уже прочитано и ограничено).
type response struct {
status int
body []byte
ctype string
}
// NewSession создаёт сессию для сервера. readonly=true выбирает отдельную
// read-only учётную запись, если она задана (иначе — основная).
func NewSession(srv *Server, readonly bool) (*Session, error) {
user, pass := srv.Username, srv.Password
label := srv.Alias
if readonly && srv.ReadonlyUsername != "" {
user, pass = srv.ReadonlyUsername, srv.ReadonlyPassword
label += "(ro)"
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("cookie jar: %w", err)
}
// #nosec G402 — InsecureSkipVerify включается осознанно оператором для
// стендов с самоподписанными сертификатами (флаг в конфиге).
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: srv.InsecureSkipVerify},
}
return &Session{
baseURL: srv.BaseURL,
username: user,
password: pass,
label: label,
client: &http.Client{Jar: jar, Transport: transport},
}, nil
}
// BaseURL возвращает адрес сервера сессии.
func (s *Session) BaseURL() string { return s.baseURL }
// ensureLogin выполняет login, если сессия ещё не авторизована.
func (s *Session) ensureLogin(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.loggedIn {
return nil
}
return s.loginLocked(ctx)
}
// loginLocked — login под уже взятым мьютексом (single-flight).
func (s *Session) loginLocked(ctx context.Context) error {
if s.username == "" || s.password == "" {
return &APIError{Status: http.StatusUnauthorized,
Message: fmt.Sprintf("для сервера %s не заданы username/password", s.label)}
}
resp, err := s.once(ctx, http.MethodPost, "/api/login", nil,
map[string]string{"username": s.username, "password": s.password})
if err != nil {
return err
}
if err := classify(resp); err != nil {
return err
}
s.loggedIn = true
return nil
}
// invalidate сбрасывает признак авторизации (cookie могла истечь).
func (s *Session) invalidate() {
s.mu.Lock()
s.loggedIn = false
s.mu.Unlock()
}
// getJSON выполняет авторизованный GET и разбирает JSON в out.
func (s *Session) getJSON(ctx context.Context, path string, query url.Values, out any) error {
resp, err := s.do(ctx, http.MethodGet, path, query, nil, true)
if err != nil {
return err
}
if err := classify(resp); err != nil {
return err
}
return decodeJSON(resp, out)
}
// postJSON выполняет авторизованный POST с JSON-телом и разбирает ответ.
func (s *Session) postJSON(ctx context.Context, path string, body, out any) error {
resp, err := s.do(ctx, http.MethodPost, path, nil, body, true)
if err != nil {
return err
}
if err := classify(resp); err != nil {
return err
}
return decodeJSON(resp, out)
}
// getPublic выполняет GET без авторизации (например, /health).
func (s *Session) getPublic(ctx context.Context, path string) (*response, error) {
return s.do(ctx, http.MethodGet, path, nil, nil, false)
}
// do выполняет запрос с автоматическим повтором: один релогin при 401 и
// (только для GET) один повтор при сетевом сбое или 5xx. Мутации НЕ
// повторяются, чтобы не выполнить действие дважды.
func (s *Session) do(ctx context.Context, method, path string, query url.Values, body any, authenticated bool) (*response, error) {
if authenticated {
if err := s.ensureLogin(ctx); err != nil {
return nil, err
}
}
attempts := 1
if method == http.MethodGet {
attempts = 2
}
var resp *response
for attempt := 1; attempt <= attempts; attempt++ {
r, err := s.once(ctx, method, path, query, body)
if err != nil {
if attempt < attempts && ctx.Err() == nil {
if err := sleepBackoff(ctx, attempt); err != nil {
return nil, err
}
continue
}
return nil, err
}
resp = r
if authenticated && resp.status == http.StatusUnauthorized {
// Cookie-сессия истекла: перелогиниваемся и повторяем ровно один раз.
s.invalidate()
if lerr := s.ensureLogin(ctx); lerr != nil {
return resp, nil
}
if r2, err2 := s.once(ctx, method, path, query, body); err2 == nil {
return r2, nil
}
return resp, nil
}
if attempt < attempts && resp.status >= 500 {
if err := sleepBackoff(ctx, attempt); err != nil {
return nil, err
}
continue
}
return resp, nil
}
return resp, nil
}
// once выполняет ровно один HTTP-запрос.
func (s *Session) once(ctx context.Context, method, path string, query url.Values, body any) (*response, error) {
u := s.baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("кодирование тела запроса: %w", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, u, reader)
if err != nil {
return nil, fmt.Errorf("сборка запроса: %w", err)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer func() { _ = res.Body.Close() }()
data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes))
if err != nil {
return nil, fmt.Errorf("чтение ответа %s: %w", path, err)
}
return &response{status: res.StatusCode, body: data, ctype: res.Header.Get("Content-Type")}, nil
}
// postMultipart загружает файлы как multipart/form-data (update_front). Файлы
// передаются потоково из памяти вызывающего; имена — относительные пути.
func (s *Session) postMultipart(ctx context.Context, path string, files []UploadFile) (json.RawMessage, error) {
if err := s.ensureLogin(ctx); err != nil {
return nil, err
}
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for _, f := range files {
part, err := writer.CreateFormFile("files", f.Name)
if err != nil {
return nil, fmt.Errorf("multipart: %w", err)
}
if _, err := part.Write(f.Data); err != nil {
return nil, fmt.Errorf("multipart: %w", err)
}
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("multipart: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, &buf)
if err != nil {
return nil, fmt.Errorf("сборка запроса: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Accept", "application/json")
res, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("POST %s: %w", path, err)
}
defer func() { _ = res.Body.Close() }()
data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes))
if err != nil {
return nil, fmt.Errorf("чтение ответа %s: %w", path, err)
}
resp := &response{status: res.StatusCode, body: data, ctype: res.Header.Get("Content-Type")}
if err := classify(resp); err != nil {
return nil, err
}
return json.RawMessage(data), nil
}
// UploadFile — файл для multipart-загрузки фронта.
type UploadFile struct {
Name string
Data []byte
}
// classify превращает HTTP-статус ≥400 в доменную APIError с текстом сервера.
// Текст берём из JSON-поля "error"; HTML/мусорные тела заменяем кратким
// сообщением, чтобы не засорять контекст модели.
func classify(resp *response) error {
if resp.status < 400 {
return nil
}
var payload struct {
Error string `json:"error"`
}
_ = json.Unmarshal(resp.body, &payload)
msg := strings.TrimSpace(payload.Error)
if msg == "" {
msg = defaultHTTPMessage(resp.status)
}
return &APIError{Status: resp.status, Message: msg}
}
// defaultHTTPMessage — краткое человекочитаемое описание статуса.
func defaultHTTPMessage(status int) string {
switch status {
case 400:
return "неверный запрос (400)"
case 401:
return "не авторизован (401)"
case 404:
return "не найдено (404)"
case 409:
return "конфликт (409)"
case 500:
return "внутренняя ошибка сервера (500)"
default:
return fmt.Sprintf("HTTP %d", status)
}
}
func decodeJSON(resp *response, out any) error {
if out == nil || len(resp.body) == 0 {
return nil
}
if err := json.Unmarshal(resp.body, out); err != nil {
return fmt.Errorf("разбор ответа сервера: %w", err)
}
return nil
}
// sleepBackoff делает паузу перед повтором, уважая отмену контекста.
func sleepBackoff(ctx context.Context, attempt int) error {
d := time.Duration(200*attempt) * time.Millisecond
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// IsDomainError сообщает, относится ли ошибка к доменным (config_server вернул
// 4xx/5xx, ошибка валидации модуля или политики вроде отсутствия front_roots),
// а не к инфраструктурным (сеть). Доменные — recoverable: текст видит модель.
func IsDomainError(err error) bool {
var apiErr *APIError
var valErr *ValidationError
if errors.As(err, &apiErr) || errors.As(err, &valErr) {
return true
}
return errors.Is(err, ErrNoFrontRoots)
}
+153
View File
@@ -0,0 +1,153 @@
package configserver
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
// mockState — разделяемое состояние тестового config_server.
type mockState struct {
mu sync.Mutex
logins int
failOnceApps bool
flakyCalls int
mutationCalls int
}
func newTestServer(t *testing.T) (*httptest.Server, *mockState) {
t.Helper()
st := &mockState{}
mux := http.NewServeMux()
writeJSON := func(w http.ResponseWriter, code int, body string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(body))
}
mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["username"] != "u" || body["password"] != "p" {
writeJSON(w, http.StatusUnauthorized, `{"error":"Invalid username or password"}`)
return
}
st.mu.Lock()
st.logins++
st.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "sess", Value: "ok", Path: "/"})
writeJSON(w, http.StatusOK, `{"status":"ok","username":"u"}`)
})
mux.HandleFunc("/api/applications", func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("sess"); err != nil || c.Value != "ok" {
writeJSON(w, http.StatusUnauthorized, `{"error":"Authentication required"}`)
return
}
st.mu.Lock()
fail := st.failOnceApps
st.failOnceApps = false
st.mu.Unlock()
if fail {
writeJSON(w, http.StatusUnauthorized, `{"error":"Authentication required"}`)
return
}
writeJSON(w, http.StatusOK, `[{"id":1,"name":"x","status":"OK"}]`)
})
mux.HandleFunc("/api/flaky", func(w http.ResponseWriter, r *http.Request) {
st.mu.Lock()
st.flakyCalls++
n := st.flakyCalls
st.mu.Unlock()
if n == 1 {
writeJSON(w, http.StatusInternalServerError, `{"error":"boom"}`)
return
}
writeJSON(w, http.StatusOK, `{"ok":true}`)
})
mux.HandleFunc("/api/mutate", func(w http.ResponseWriter, r *http.Request) {
st.mu.Lock()
st.mutationCalls++
st.mu.Unlock()
writeJSON(w, http.StatusInternalServerError, `{"error":"boom"}`)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts, st
}
func newTestSession(t *testing.T, url string) *Session {
t.Helper()
s, err := NewSession(&Server{Alias: "t", BaseURL: url, Username: "u", Password: "p"}, false)
if err != nil {
t.Fatalf("NewSession: %v", err)
}
return s
}
func TestSessionLoginAndGet(t *testing.T) {
ts, st := newTestServer(t)
s := newTestSession(t, ts.URL)
var apps []Application
if err := s.getJSON(context.Background(), "/api/applications", nil, &apps); err != nil {
t.Fatalf("getJSON: %v", err)
}
if len(apps) != 1 || apps[0].Name != "x" {
t.Fatalf("неожиданный ответ: %+v", apps)
}
if st.logins != 1 {
t.Fatalf("logins = %d, ожидалось 1", st.logins)
}
}
func TestSessionReloginOn401(t *testing.T) {
ts, st := newTestServer(t)
s := newTestSession(t, ts.URL)
st.failOnceApps = true
var apps []Application
if err := s.getJSON(context.Background(), "/api/applications", nil, &apps); err != nil {
t.Fatalf("getJSON после relogin: %v", err)
}
if st.logins != 2 {
t.Fatalf("logins = %d, ожидалось 2 (релогin при 401)", st.logins)
}
}
func TestGetRetriedOn5xx(t *testing.T) {
ts, st := newTestServer(t)
s := newTestSession(t, ts.URL)
var out map[string]any
if err := s.getJSON(context.Background(), "/api/flaky", nil, &out); err != nil {
t.Fatalf("getJSON flaky: %v", err)
}
if st.flakyCalls != 2 {
t.Fatalf("GET-повтор не сработал: calls = %d", st.flakyCalls)
}
}
func TestMutationNotRetried(t *testing.T) {
ts, st := newTestServer(t)
s := newTestSession(t, ts.URL)
var out map[string]any
err := s.postJSON(context.Background(), "/api/mutate", map[string]string{"x": "y"}, &out)
if err == nil {
t.Fatal("ожидалась ошибка 500")
}
if st.mutationCalls != 1 {
t.Fatalf("мутация НЕ должна повторяться: calls = %d", st.mutationCalls)
}
if !IsDomainError(err) {
t.Fatalf("ошибка 500 должна классифицироваться как доменная: %v", err)
}
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// configure.go — изменяющие инструменты конфигурации (compose/env и версии).
// Требуют read_only=false и обычно помечаются require_approval в agent.yaml.
func registerConfigureTools(s *mcp.Server) {
addPatternTool(s, &mcp.Tool{
Name: "set_compose",
Description: "Заменить текущий docker-compose сервиса, создать версию и отправить агенту. Изменяет конфигурацию.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
"content": strProps("Полный текст docker-compose (YAML)", true),
}, []string{"app_id", "content"}),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
content, err := requireString(args, "content")
if err != nil {
return errorResult(err.Error()), nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.SetCompose(ctx, id, content))
})
addPatternTool(s, &mcp.Tool{
Name: "set_env",
Description: "Заменить текущий env-файл сервиса (например .env), создать версию и отправить агенту. Изменяет конфигурацию.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
"filename": strProps("Имя env-файла, например .env", true),
"content": strProps("Полное содержимое env-файла", true),
}, []string{"app_id", "filename", "content"}),
}, envPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
filename, err := requireString(args, "filename")
if err != nil {
return errorResult(err.Error()), nil
}
content, err := requireString(args, "content")
if err != nil {
return errorResult(err.Error()), nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.SetEnv(ctx, id, filename, content))
})
addPatternTool(s, &mcp.Tool{
Name: "restore_compose",
Description: "Восстановить версию compose (создаёт новую текущую версию) и отправить агенту. Разрушительно: требует confirm=\"true\".",
InputSchema: schema(map[string]any{
"server": serverProp(),
"version_id": intProps("Идентификатор версии compose", true),
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
}, []string{"version_id", "confirm"}),
}, versionPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
if res := confirmGate(args, "restore_compose"); res != nil {
return res, nil
}
id, res := versionIDArg(args)
if res != nil {
return res, nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.RestoreCompose(ctx, id))
})
addPatternTool(s, &mcp.Tool{
Name: "restore_env_version",
Description: "Восстановить версию env-файла (создаёт новую текущую версию) и отправить агенту. Разрушительно: требует confirm=\"true\".",
InputSchema: schema(map[string]any{
"server": serverProp(),
"version_id": intProps("Идентификатор версии env", true),
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
}, []string{"version_id", "confirm"}),
}, versionPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
if res := confirmGate(args, "restore_env_version"); res != nil {
return res, nil
}
id, res := versionIDArg(args)
if res != nil {
return res, nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.RestoreEnvVersion(ctx, id))
})
addPatternTool(s, &mcp.Tool{
Name: "update_config",
Description: "Повторно отправить агенту текущую конфигурацию сервиса из БД (compose + env). Изменяет файлы на хосте.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
}, []string{"app_id"}),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.UpdateConfig(ctx, id))
})
}
+38
View File
@@ -0,0 +1,38 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// maintain.go — обслуживание: очистка старых версий.
func registerMaintainTools(s *mcp.Server) {
// cleanup_old_versions не эмитит паттерн по конкретному ресурсу: операция
// затрагивает всю БД версий, поэтому используем release-паттерн стенда.
addPatternTool(s, &mcp.Tool{
Name: "cleanup_old_versions",
Description: "Удалить версии compose/env и логи миграций старше N дней (текущие версии не трогаются). Разрушительно: требует confirm=\"true\".",
InputSchema: schema(map[string]any{
"server": serverProp(),
"days": intProps("Порог в днях (по умолчанию 90)", false),
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
}, []string{"confirm"}),
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
if res := confirmGate(args, "cleanup_old_versions"); res != nil {
return res, nil
}
days := getInt(args, "days", 90)
if days <= 0 {
return errorResult("days должен быть положительным"), nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.CleanupOldVersions(ctx, days))
})
}
+338
View File
@@ -0,0 +1,338 @@
package tools
import (
"context"
"omnichannel-mcp/internal/configserver"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// observe.go — read-only инструменты: наблюдение, диагностика, аудит.
// observeCall — общий каркас read-инструмента: открыть API, вызвать домен,
// вернуть результат. Убирает повторяющуюся обвязку из хендлеров.
func observeCall(ctx context.Context, req *mcp.CallToolRequest, call func(*configserver.API) (any, error)) (*mcp.CallToolResult, error) {
args := requestArgs(req)
api, res := openAPI(ctx, args, false)
if res != nil {
return res, nil
}
defer api.Close()
v, err := call(api)
return result(v, err)
}
func registerObserveTools(s *mcp.Server) {
registerHealthTools(s)
registerApplicationTools(s)
registerConfigReadTools(s)
registerTaskTools(s)
registerDeploymentReadTools(s)
registerSettingsTools(s)
}
func registerHealthTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "server_health",
Description: "Живость config_server (GET /health, без авторизации). Read-only. Помогает отличить проблему стенда от проблемы авторизации.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
status, body, err := api.Health(ctx)
if err != nil {
return nil, err
}
return map[string]any{"server": api.Alias(), "http_status": status, "body": body}, nil
})
})
addTool(s, &mcp.Tool{
Name: "whoami",
Description: "Проверить авторизацию: кто залогинен на config_server. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.WhoAmI(ctx)
})
})
addTool(s, &mcp.Tool{
Name: "server_info",
Description: "Сведения о сервере и версии API (capability-probe): алиас, адрес, режим read_only, доступные JSON-эндпоинты. Read-only. Вызывай первым при неясностях.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
caps, err := api.Probe(ctx)
if err != nil {
return nil, err
}
return map[string]any{
"server": api.Alias(),
"base_url": api.Srv.BaseURL,
"read_only": api.Config.ReadOnly,
"readonly_creds": api.Srv.ReadonlyUsername != "",
"capabilities": caps,
}, nil
})
})
}
func registerApplicationTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "list_applications",
Description: "Список сервисов с хостом и статусом. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"include_presence": boolProps("Включить служебные записи хостов (__agent_presence__)", false),
}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.ListApplications(ctx, getBool(args, "include_presence", false))
})
})
addTool(s, &mcp.Tool{
Name: "get_application",
Description: "Детализация сервиса: статус, текущий compose, версии compose, env-файлы и их содержимое, журнал миграций. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
}, []string{"app_id"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.GetApplication(ctx, id)
})
})
}
func registerConfigReadTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "get_config",
Description: "Текущие compose и env сервиса из БД. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
}, []string{"app_id"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.GetConfig(ctx, id)
})
})
addTool(s, &mcp.Tool{
Name: "env_files",
Description: "Список текущих env-файлов сервиса. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
}, []string{"app_id"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.EnvFiles(ctx, id)
})
})
addTool(s, &mcp.Tool{
Name: "compose_version",
Description: "Содержимое конкретной версии compose по version_id. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"version_id": intProps("Идентификатор версии compose", true),
}, []string{"version_id"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := versionIDArg(args)
if res != nil {
return res, nil
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.ComposeVersion(ctx, id)
})
})
addTool(s, &mcp.Tool{
Name: "env_version",
Description: "Содержимое конкретной версии env-файла по version_id. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"version_id": intProps("Идентификатор версии env", true),
}, []string{"version_id"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := versionIDArg(args)
if res != nil {
return res, nil
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.EnvVersion(ctx, id)
})
})
addTool(s, &mcp.Tool{
Name: "env_versions",
Description: "Все версии конкретного env-файла сервиса. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
"filename": strProps("Имя env-файла, например .env", true),
}, []string{"app_id", "filename"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
id, res := appIDArg(args)
if res != nil {
return res, nil
}
filename, err := requireString(args, "filename")
if err != nil {
return errorResult(err.Error()), nil
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.EnvVersions(ctx, id, filename)
})
})
}
func registerTaskTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "list_tasks",
Description: "Список задач агентов с фильтрами (аудит, поиск failed/pending). Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"host_ip": strProps("Фильтр по IP хоста", false),
"status": strProps("Фильтр по статусу", false, "pending", "in_progress", "completed", "failed"),
"task_type": strProps("Фильтр по типу задачи (deploy, restart, down, migration, update_config, release_sync, release_start, update_front)", false),
"limit": intProps("Максимум записей (1..500, по умолчанию 100)", false),
}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
filter := configserver.TaskFilter{
HostIP: getString(args, "host_ip", ""),
Status: getString(args, "status", ""),
TaskType: getString(args, "task_type", ""),
Limit: getInt(args, "limit", 100),
}
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.ListTasks(ctx, filter)
})
})
addTool(s, &mcp.Tool{
Name: "get_task",
Description: "Статус и вывод задачи. wait=true — дождаться завершения (ограничено task_poll_max_sec); иначе вернуть текущее состояние. Read-only.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"task_id": strProps("UUID задачи", true),
"wait": boolProps("Дождаться завершения (по умолчанию false)", false),
"tail": boolProps("Оставить хвост вывода при обрезке (по умолчанию true — ошибки обычно в конце)", false),
"output_bytes": intProps("Лимит вывода в байтах (по умолчанию — max_output_bytes сервера)", false),
}, []string{"task_id"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
taskID, err := requireString(args, "task_id")
if err != nil {
return errorResult(err.Error()), nil
}
wait := getBool(args, "wait", false)
tail := getBool(args, "tail", true)
limit := getInt(args, "output_bytes", 0)
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
var task *configserver.Task
if wait {
task, err = api.WaitTask(ctx, taskID)
} else {
task, err = api.GetTask(ctx, taskID)
}
if err != nil {
return nil, err
}
if limit <= 0 {
limit = api.Srv.MaxOutputBytes
}
out := *task
out.Output, _ = truncateText(out.Output, limit, tail)
return out, nil
})
})
}
func registerDeploymentReadTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "get_deployment_files",
Description: "Файлы развёртывания: schema, manifest, prebuild_vars, ip_overrides. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.DeploymentFiles(ctx)
})
})
addTool(s, &mcp.Tool{
Name: "ip_match",
Description: "Диагностика сопоставления IP из schema.json с IP агентов в БД. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.IPMatch(ctx)
})
})
addTool(s, &mcp.Tool{
Name: "release_job",
Description: "Статус фоновой загрузки релиза (status: idle/running/completed/failed, фаза, прогресс образов). Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.ReleaseJob(ctx)
})
})
addTool(s, &mcp.Tool{
Name: "deployment_tasks",
Description: "Последние release-задачи (sync/start). Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.DeploymentTasks(ctx)
})
})
}
func registerSettingsTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "stats",
Description: "Статистика: количество версий compose/env и логов миграций. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.Stats(ctx)
})
})
addTool(s, &mcp.Tool{
Name: "service_map",
Description: "Карта хостов и сервисов. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
return api.ServiceMap(ctx)
})
})
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"omnichannel-mcp/internal/configserver"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// operate.go — жизненный цикл сервиса (deploy/restart/down/migrate) и загрузка
// фронта. Мутации; обычно require_approval в agent.yaml.
func registerOperateTools(s *mcp.Server) {
addPatternTool(s, &mcp.Tool{
Name: "deploy",
Description: "Поднять сервис (docker-compose up -d). Создаёт задачу агенту; wait=true — дождаться результата.",
InputSchema: lifecycleSchema(),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return runLifecycle(ctx, req, "deploy", false)
})
addPatternTool(s, &mcp.Tool{
Name: "restart",
Description: "Перезапустить сервис (down + up -d). Создаёт задачу агенту; wait=true — дождаться результата.",
InputSchema: lifecycleSchema(),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return runLifecycle(ctx, req, "restart", false)
})
addPatternTool(s, &mcp.Tool{
Name: "migrate",
Description: "Запустить миграции сервиса (docker-compose run migration). Создаёт задачу агенту.",
InputSchema: lifecycleSchema(),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return runLifecycle(ctx, req, "migrate", false)
})
addPatternTool(s, &mcp.Tool{
Name: "down",
Description: "Остановить сервис (docker-compose down). Разрушительно: требует confirm=\"true\".",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
"wait": boolProps("Дождаться завершения (по умолчанию false)", false),
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
}, []string{"app_id", "confirm"}),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return runLifecycle(ctx, req, "down", true)
})
addPatternTool(s, &mcp.Tool{
Name: "update_front",
Description: "Загрузить локальную сборку фронта на хост сервиса (полная замена build_folder). Разрушительно: требует confirm=\"true\". Каталог — внутри front_roots.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор фронт-сервиса", true),
"build_dir": strProps("Локальный каталог сборки (абсолютный путь внутри front_roots)", true),
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
}, []string{"app_id", "build_dir", "confirm"}),
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
if res := confirmGate(args, "update_front"); res != nil {
return res, nil
}
id, res := appIDArg(args)
if res != nil {
return res, nil
}
buildDir, err := requireString(args, "build_dir")
if err != nil {
return errorResult(err.Error()), nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.UpdateFront(ctx, id, buildDir))
})
}
// lifecycleSchema — общая схема deploy/restart/migrate/down.
func lifecycleSchema() map[string]any {
return schema(map[string]any{
"server": serverProp(),
"app_id": intProps("Идентификатор сервиса", true),
"wait": boolProps("Дождаться завершения задачи (по умолчанию false)", false),
}, []string{"app_id"})
}
// runLifecycle — общий путь операций жизненного цикла: вызвать действие, при
// wait=true ограниченно дождаться задачи агента.
func runLifecycle(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
args := requestArgs(req)
if needConfirm {
if res := confirmGate(args, action); res != nil {
return res, nil
}
}
id, res := appIDArg(args)
if res != nil {
return res, nil
}
wait := getBool(args, "wait", false)
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
var (
ack *configserver.TaskAck
err error
)
switch action {
case "deploy":
ack, err = api.Deploy(ctx, id)
case "restart":
ack, err = api.Restart(ctx, id)
case "down":
ack, err = api.Down(ctx, id)
case "migrate":
ack, err = api.Migrate(ctx, id)
}
if err != nil {
return result(nil, err)
}
// Без wait отдаём task_id, чтобы не блокировать последовательный loop агента.
if !wait {
return result(ack, nil)
}
task, err := api.WaitTask(ctx, ack.TaskID)
if err != nil {
return result(nil, err)
}
return result(map[string]any{"ack": ack, "task": task}, nil)
}
+109
View File
@@ -0,0 +1,109 @@
package tools
import (
"context"
"omnichannel-mcp/internal/configserver"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// overview.go — сводные read-only инструменты для эксплуатации. Они не
// изменяют состояние, а собирают картину несколькими GET-вызовами (best-effort:
// сбой одного источника не рушит остальные, а попадает в поле errors).
func registerOverviewTools(s *mcp.Server) {
addTool(s, &mcp.Tool{
Name: "overview",
Description: "Сводка состояния стенда одним вызовом: health, статистика, карта сервисов, приложения не в OK, последние failed-задачи. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, handleOverview)
addTool(s, &mcp.Tool{
Name: "release_status",
Description: "Состояние развёртывания релиза: release_job, release-задачи, приложения, сопоставление IP. Read-only.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, handleReleaseStatus)
}
func handleOverview(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
out := map[string]any{"server": api.Alias(), "read_only": api.Config.ReadOnly}
var errs []string
if status, body, err := api.Health(ctx); err != nil {
errs = append(errs, "health: "+err.Error())
} else {
out["health"] = map[string]any{"http_status": status, "body": body}
}
if st, err := api.Stats(ctx); err != nil {
errs = append(errs, "stats: "+err.Error())
} else {
out["stats"] = st
}
if sm, err := api.ServiceMap(ctx); err != nil {
errs = append(errs, "service_map: "+err.Error())
} else {
out["service_map"] = sm
}
if apps, err := api.ListApplications(ctx, false); err != nil {
errs = append(errs, "applications: "+err.Error())
} else {
out["unhealthy_applications"] = unhealthy(apps)
}
if tasks, err := api.ListTasks(ctx, configserver.TaskFilter{Status: "failed", Limit: 10}); err != nil {
errs = append(errs, "failed_tasks: "+err.Error())
} else {
out["recent_failed_tasks"] = tasks
}
if len(errs) > 0 {
out["errors"] = errs
}
return out, nil
})
}
func handleReleaseStatus(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
out := map[string]any{"server": api.Alias()}
var errs []string
if job, err := api.ReleaseJob(ctx); err != nil {
errs = append(errs, "release_job: "+err.Error())
} else {
out["release_job"] = job
}
if tasks, err := api.DeploymentTasks(ctx); err != nil {
errs = append(errs, "deployment_tasks: "+err.Error())
} else {
out["deployment_tasks"] = tasks
}
if apps, err := api.ListApplications(ctx, false); err != nil {
errs = append(errs, "applications: "+err.Error())
} else {
out["applications"] = apps
}
if ipm, err := api.IPMatch(ctx); err != nil {
errs = append(errs, "ip_match: "+err.Error())
} else {
out["ip_match"] = ipm
}
if len(errs) > 0 {
out["errors"] = errs
}
return out, nil
})
}
// unhealthy возвращает приложения со статусом, отличным от OK.
func unhealthy(apps []configserver.Application) []configserver.Application {
out := []configserver.Application{}
for _, app := range apps {
if app.Status != "OK" {
out = append(out, app)
}
}
return out
}
+70
View File
@@ -0,0 +1,70 @@
package tools
import (
"strconv"
"strings"
)
// patterns.go — доменные эмиттеры probe-паттернов разрешений для omnichannel-mcp.
//
// Канонический паттерн несёт алиас сервера и ресурс, чтобы мультисерверная
// политика оператора могла различать стенды:
//
// server=<alias> app=<id>
// server=<alias> app=<id> file=<filename>
// server=<alias> version=<id>
// server=<alias> release
//
// «always» не эмитим: каждая разрушительная операция должна получать отдельный
// ask/allow у оператора (как в proxmox-модуле).
// effectiveServer возвращает алиас сервера для паттерна: явный аргумент либо
// default из конфига. Без конфига — "default".
func effectiveServer(args map[string]any) string {
if s := getString(args, "server", ""); s != "" {
return s
}
if m := manager(); m != nil {
if cfg, _, err := m.Config(configPathArg(args)); err == nil && cfg.Default != "" {
return cfg.Default
}
}
return "default"
}
func serverPart(args map[string]any) string { return "server=" + effectiveServer(args) }
// appPatterns — паттерн ресурса-приложения (deploy/restart/down/migrate/...).
func appPatterns(args map[string]any) ([]string, []string) {
parts := []string{serverPart(args)}
if id := getInt(args, "app_id", 0); id > 0 {
parts = append(parts, "app="+strconv.Itoa(id))
}
return []string{strings.Join(parts, " ")}, nil
}
// envPatterns — паттерн env-файла (set_env/restore_env_version).
func envPatterns(args map[string]any) ([]string, []string) {
parts := []string{serverPart(args)}
if id := getInt(args, "app_id", 0); id > 0 {
parts = append(parts, "app="+strconv.Itoa(id))
}
if fn := getString(args, "filename", ""); fn != "" {
parts = append(parts, "file="+fn)
}
return []string{strings.Join(parts, " ")}, nil
}
// versionPatterns — паттерн версии конфигурации (restore_compose).
func versionPatterns(args map[string]any) ([]string, []string) {
parts := []string{serverPart(args)}
if id := getInt(args, "version_id", 0); id > 0 {
parts = append(parts, "version="+strconv.Itoa(id))
}
return []string{strings.Join(parts, " ")}, nil
}
// releasePatterns — паттерн операций релиза/развёртывания.
func releasePatterns(args map[string]any) ([]string, []string) {
return []string{serverPart(args) + " release"}, nil
}
+101
View File
@@ -0,0 +1,101 @@
// Package tools реализует MCP-инструменты модуля omnichannel-mcp поверх
// доменного пакета configserver. Слой тонкий: разобрать аргументы, вызвать
// домен, вернуть текст/ошибку. Бизнес-логики здесь нет.
package tools
import (
"context"
"omnichannel-mcp/internal/configserver"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// mgr — одиночный Manager процесса (см. ARCHITECTURE.md §2). Устанавливается
// один раз в RegisterAll; Manager потокобезопасен.
var mgr *configserver.Manager
func manager() *configserver.Manager { return mgr }
// RegisterAll — единая точка регистрации инструментов, сгруппированных по
// фазам жизненного цикла: observe (чтение) → configure → operate → release →
// maintain. Имена инструментов — без префикса (префикс может добавить MCP-хост).
func RegisterAll(s *mcp.Server, m *configserver.Manager) {
mgr = m
registerObserveTools(s)
registerConfigureTools(s)
registerOperateTools(s)
registerReleaseTools(s)
registerMaintainTools(s)
registerOverviewTools(s)
}
// openAPI резолвит конфиг/сервер и открывает фасад API. Любая ошибка на этом
// шаге (нет конфига, неизвестный алиас, read_only) — доменная: отдаём модели.
func openAPI(ctx context.Context, args map[string]any, write bool) (*configserver.API, *mcp.CallToolResult) {
m := manager()
if m == nil {
return nil, errorResult("omnichannel-mcp: manager не инициализирован")
}
alias := getString(args, "server", "")
api, err := m.Open(ctx, configPathArg(args), alias, write)
if err != nil {
return nil, errorResult(err.Error())
}
return api, nil
}
// configPathArg возвращает опциональный путь к конфигу из аргументов вызова.
// Основной способ задания конфига — флаг `-config`; per-call переопределение
// (`config_path`) поддержано для многотенантных обёрток. `_tenant_config` —
// устаревший алиас, принимается для совместимости.
func configPathArg(args map[string]any) string {
if v := getString(args, "config_path", ""); v != "" {
return v
}
return getString(args, "_tenant_config", "")
}
// result приводит (значение, ошибка) домена к результату инструмента:
// доменные ошибки — recoverable (текст видит модель), инфраструктурные — Go-ошибка.
func result(v any, err error) (*mcp.CallToolResult, error) {
if err != nil {
if configserver.IsDomainError(err) {
return errorResult(err.Error()), nil
}
return nil, err
}
return textResult(jsonText(v)), nil
}
// confirmGate требует confirm="true" для разрушительных операций. Это второй
// слой поверх require_approval в agent.yaml.
func confirmGate(args map[string]any, what string) *mcp.CallToolResult {
if !getBool(args, "confirm", false) {
return errorResult("операция \"" + what + "\" требует confirm=\"true\"")
}
return nil
}
// serverProp — общее описание аргумента выбора сервера.
func serverProp() map[string]any {
return strProps("Алиас сервера из config.json (по умолчанию — default)", false)
}
// appIDArg читает обязательный app_id.
func appIDArg(args map[string]any) (int, *mcp.CallToolResult) {
id := getInt(args, "app_id", 0)
if id <= 0 {
return 0, errorResult("нужен положительный app_id")
}
return id, nil
}
// versionIDArg читает обязательный version_id.
func versionIDArg(args map[string]any) (int, *mcp.CallToolResult) {
id := getInt(args, "version_id", 0)
if id <= 0 {
return 0, errorResult("нужен положительный version_id")
}
return id, nil
}
+118
View File
@@ -0,0 +1,118 @@
package tools
import (
"context"
"omnichannel-mcp/internal/configserver"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// release.go — страница «Развёртывание»: файлы схемы, seed/fill, релиз.
func registerReleaseTools(s *mcp.Server) {
addPatternTool(s, &mcp.Tool{
Name: "save_deployment_files",
Description: "Сохранить файлы развёртывания (schema/manifest/prebuild_vars/ip_overrides). Передавай только изменяемые поля.",
InputSchema: schema(map[string]any{
"server": serverProp(),
"schema": strProps("JSON-схема развёртывания (опционально)", false),
"manifest": strProps("JSON-манифест (опционально)", false),
"prebuild_vars": strProps("Строка prebuild_vars (опционально)", false),
"ip_overrides": strProps("JSON ip_overrides (опционально)", false),
}, nil),
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
upd := configserver.DeploymentUpdate{
Schema: optionalString(args, "schema"),
Manifest: optionalString(args, "manifest"),
PrebuildVars: optionalString(args, "prebuild_vars"),
IPOverrides: optionalString(args, "ip_overrides"),
}
if upd.Schema == nil && upd.Manifest == nil && upd.PrebuildVars == nil && upd.IPOverrides == nil {
return errorResult("нужно передать хотя бы одно из полей: schema, manifest, prebuild_vars, ip_overrides"), nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.SaveDeploymentFiles(ctx, upd))
})
addPatternTool(s, &mcp.Tool{
Name: "seed_hosts",
Description: "Создать служебные записи хостов по IP из schema.json. Изменяет БД.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.SeedHosts(ctx))
})
addPatternTool(s, &mcp.Tool{
Name: "fill_vars",
Description: "Заполнить prebuild_vars из schema.json. Изменяет файл конфигурации развёртывания.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
v, err := api.FillVars(ctx)
return result(v, err)
})
addPatternTool(s, &mcp.Tool{
Name: "download_release",
Description: "Загрузить релиз. load_images=false — синхронно (пакеты + sync-задачи); true — фоновая загрузка образов (прогресс через release_job). Разрушительно/тяжело: требует confirm=\"true\".",
InputSchema: schema(map[string]any{
"server": serverProp(),
"load_images": boolProps("Загружать docker-образы в фоне (по умолчанию false)", false),
"gitlab_token": strProps("GitLab-токен (опционально; иначе из конфига/env сервера)", false),
"confirm": boolProps("Подтверждение операции (обязательно true)", true),
}, []string{"confirm"}),
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
if res := confirmGate(args, "download_release"); res != nil {
return res, nil
}
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
ack, err := api.DownloadRelease(ctx, getString(args, "gitlab_token", ""), getBool(args, "load_images", false))
return result(ack, err)
})
addPatternTool(s, &mcp.Tool{
Name: "start_services",
Description: "Создать задачи запуска сервисов для хостов из schema.json. Изменяет состояние стенда.",
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := requestArgs(req)
api, res := openAPI(ctx, args, true)
if res != nil {
return res, nil
}
defer api.Close()
return result(api.StartServices(ctx))
})
}
// optionalString возвращает указатель на строковый аргумент, если он задан
// (в том числе пустой строкой), иначе nil.
func optionalString(args map[string]any, key string) *string {
if _, ok := args[key]; !ok {
return nil
}
v := getString(args, key, "")
return &v
}
+60
View File
@@ -0,0 +1,60 @@
package tools
import (
"encoding/json"
"git.totmin.ru/en2zmax/forge-toolkit"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// toolkit.go — тонкие адаптеры к общему тулкиту (модуль forge-toolkit).
// Локальные имена сохраняют хендлеры независимыми от пакета-источника.
func requestArgs(req *mcp.CallToolRequest) map[string]any { return toolkit.RequestArgs(req) }
func getString(args map[string]any, key, def string) string { return toolkit.GetString(args, key, def) }
func requireString(args map[string]any, key string) (string, error) {
return toolkit.RequireString(args, key)
}
func getInt(args map[string]any, key string, def int) int { return toolkit.GetInt(args, key, def) }
func getBool(args map[string]any, key string, def bool) bool { return toolkit.GetBool(args, key, def) }
func schema(properties map[string]any, required []string) map[string]any {
return toolkit.Schema(properties, required)
}
func strProps(desc string, required bool, enum ...string) map[string]any {
return toolkit.StrProps(desc, required, enum...)
}
func intProps(desc string, required bool) map[string]any { return toolkit.IntProps(desc, required) }
func boolProps(desc string, required bool) map[string]any { return toolkit.BoolProps(desc, required) }
func textResult(text string) *mcp.CallToolResult { return toolkit.Text(text) }
func errorResult(msg string) *mcp.CallToolResult { return toolkit.Error(msg) }
// addTool регистрирует инструмент без probe-паттернов (read-only).
func addTool(s *mcp.Server, tool *mcp.Tool, h mcp.ToolHandler) { s.AddTool(tool, h) }
// patternsFn — эмиттер permission-паттернов разрешений для probe-запросов.
type patternsFn = toolkit.PatternsFn
// addPatternTool регистрирует изменяющий инструмент с поддержкой probe: хост
// получает канонический паттерн ресурса, не выполняя действие.
func addPatternTool(s *mcp.Server, tool *mcp.Tool, fn patternsFn, h mcp.ToolHandler) {
toolkit.RegisterPatternTool(s, tool, fn, h)
}
// jsonText сериализует значение в читаемый JSON для ответа модели.
func jsonText(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "{}"
}
return string(b)
}
+19
View File
@@ -0,0 +1,19 @@
package tools
import "strconv"
// util.go — небольшие помощники MCP-слоя.
// truncateText ограничивает длинный вывод (задачи, compose, env), чтобы он не
// съел контекст модели. По умолчанию оставляем хвост: ошибки сборки/деплоя
// обычно в конце вывода. Второй результат — был ли вывод обрезан.
func truncateText(s string, maxBytes int, tail bool) (string, bool) {
if maxBytes <= 0 || len(s) <= maxBytes {
return s, false
}
cut := len(s) - maxBytes
if tail {
return "…(обрезано " + strconv.Itoa(cut) + " байт, показан хвост)\n" + s[len(s)-maxBytes:], true
}
return s[:maxBytes] + "\n…(обрезано " + strconv.Itoa(cut) + " байт, показано начало)", true
}
+104
View File
@@ -0,0 +1,104 @@
// Command omnichannel-mcp — MCP-сервер управления платформой Omnichannel через
// HTTP API config_server v1.1.0+ (деплой и эксплуатация).
//
// Следует общим соглашениям MCP-серверов: stdio-only, один потокобезопасный
// Manager, ручные JSON-схемы, отдельный домен, конфиг с live-reload,
// probe-паттерны разрешений на изменяющих инструментах.
//
// Логи пишутся ТОЛЬКО в stderr: stdout занят JSON-RPC протоколом.
package main
import (
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"strings"
"omnichannel-mcp/internal/configserver"
"omnichannel-mcp/internal/tools"
"git.totmin.ru/en2zmax/forge-toolkit"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// serverName — имя MCP-сервера; часть хостов добавляет префикс serverName+"__"
// к именам инструментов.
const serverName = "omnichannel-mcp"
func main() {
// --health должен быть дешёвым: без чтения конфига и подключений.
if toolkit.Health() {
return
}
configPath := flag.String("config", "", "путь к config.json (иначе ищется OMNI_CONFIG_DIR/omnichannel-mcp.json)")
checkConfig := flag.Bool("check-config", false, "проверить конфиг, напечатать (секреты маскируются) и выйти")
logLevel := flag.String("log-level", envOr("OMNI_LOG_LEVEL", "info"), "уровень логов: debug|info|warn|error")
flag.Parse()
setupLogger(*logLevel)
if *checkConfig {
os.Exit(runCheckConfig(*configPath))
}
mgr := configserver.NewManager(*configPath)
defer mgr.Close()
if err := toolkit.Run(serverName, func(s *mcp.Server) {
tools.RegisterAll(s, mgr)
}); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", serverName, err)
os.Exit(1)
}
}
// runCheckConfig печатает разрешённый конфиг (с маской секретов) — для проверки
// настройки до подключения клиента и в CI. Возвращает код выхода.
func runCheckConfig(cliPath string) int {
mgr := configserver.NewManager(cliPath)
path := mgr.ResolvePath("")
if path == "" {
fmt.Fprintln(os.Stderr, "config: путь не задан (нужен -config или OMNI_CONFIG_DIR)")
return 1
}
cfg, _, err := mgr.Config("")
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
return 1
}
out, err := json.MarshalIndent(cfg.Redacted(), "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
return 1
}
fmt.Printf("config: %s\n%s\n", path, out)
fmt.Printf("servers: %s (default: %s)\n", strings.Join(cfg.ServerNames(), ", "), cfg.Default)
return 0
}
// setupLogger направляет логи в stderr с выбранным уровнем.
func setupLogger(level string) {
var lvl slog.Level
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
lvl = slog.LevelDebug
case "warn", "warning":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
slog.SetDefault(slog.New(handler))
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}