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
+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
}