Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user