Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE

This commit is contained in:
Maksim Totmin
2026-10-01 10:41:43 +07:00
commit a7addaead9
30 changed files with 4226 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"forge-tools-proxmox/internal/pve"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// helpers.go — общие помощники инструментов: доступ к тенанту, валидация
// идентификаторов (guard), гейты записи (read_only + allowlist + confirm),
// таймауты и форматирование JSON. Никакой бизнес-логики — только трансляция
// запрос/ответ (см. ARCHITECTURE.md §4 «золотое правило разделения»).
var mgr *pve.Manager
// SetManager связывает одиночный Manager процесса (forge: 1 подпроцесс).
func SetManager(m *pve.Manager) { mgr = m }
// manager возвращает одиночный Manager.
func manager() *pve.Manager { return mgr }
// tenantFor резолвит per-agent тенант из серверного аргумента _tenant_config
// (инжектится ядром в pooled-режиме). Пусто — статический -config. Ошибка
// загрузки конфига — доменная (errorResult), а не падение процесса.
func tenantFor(ctx context.Context, req *mcp.CallToolRequest) (*pve.Tenant, error) {
m := manager()
if m == nil {
return nil, errors.New("proxmox: no manager initialized")
}
path := getString(requestArgs(req), "_tenant_config", "")
return m.Tenant(ctx, path)
}
// timeout контекст по конфигу тенанта (bounded §9.6).
func timeout(ctx context.Context, t *pve.Tenant) (context.Context, context.CancelFunc) {
sec := t.Config().TimeoutSec
if sec <= 0 {
sec = pve.DefaultTimeoutSec
}
return context.WithTimeout(ctx, time.Duration(sec)*time.Second)
}
// requireNode возвращает node с guard-валидацией идентификатора.
func requireNode(args map[string]any) (string, error) {
node, err := requireString(args, "node")
if err != nil {
return "", err
}
if err := pve.ValidateIdentifier(node); err != nil {
return "", err
}
return node, nil
}
// requireVMID возвращает vmid (int или token-string) с валидацией.
func requireVMID(args map[string]any) (int, error) {
raw := getString(args, "vmid", "")
if raw == "" {
return 0, errors.New("missing required argument 'vmid'")
}
if err := pve.ValidateIdentifier(raw); err != nil {
return 0, err
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return 0, fmt.Errorf("argument 'vmid' must be a positive integer, got %q", raw)
}
return v, nil
}
// requireName возвращает имя (snapshot/storage) с guard-валидацией.
func requireName(args map[string]any, key string) (string, error) {
name, err := requireString(args, key)
if err != nil {
return "", err
}
if err := pve.ValidateIdentifier(name); err != nil {
return "", err
}
return name, nil
}
// requireUPID возвращает UPID с отдельным guard (допускает ':' '@' '!').
func requireUPID(args map[string]any) (string, error) {
upid, err := requireString(args, "upid")
if err != nil {
return "", err
}
if err := pve.ValidateUPID(upid); err != nil {
return "", err
}
return upid, nil
}
// resolveHost выбирает целевой гипервизор (алиас). Правила:
// - host задан → валидируем, что он настроен (иначе fail-closed);
// - не задан и хостов больше одного → отказ (неоднозначность: VMID/ноды на
// разных гипервизорах могут пересекаться, поэтому цель обязана быть явной);
// - не задан и хост один → пустая строка (= default, резолвится в Client).
func resolveHost(t *pve.Tenant, args map[string]any) (string, error) {
h := getString(args, "host", "")
if h != "" {
if !t.Config().HostExists(h) {
return "", fmt.Errorf("host %q not configured", h)
}
return h, nil
}
if t.Config().MultiHost() {
return "", errors.New("specify 'host' — multiple hypervisors configured")
}
return "", nil
}
// confirm проверяет явный флаг "confirm": "true" для деструктивных операций.
// Без него — отказ ДО обращения к API (безопасность §9).
func confirm(args map[string]any, what string) error {
if getString(args, "confirm", "") != "true" {
return fmt.Errorf("%s requires confirm=\"true\" argument (destructive)", what)
}
return nil
}
// gateVMWrite применяет политику записи к VM/CT на конкретный гипервизор:
// fail-closed по read_only и per-host allowlist.vmids (или глобальному
// фолбэку для одиночного гипервизора). Это второй слой поверх
// least-privilege токена (§9.5), и он исключает коллизию VMID между хостами.
func gateVMWrite(t *pve.Tenant, host string, vmid int) error {
if !t.Config().WriteAllowed(host, vmid) {
return fmt.Errorf("proxmox: write to vmid %d on host %q not allowed (read_only or allowlist.vmids)", vmid, host)
}
return nil
}
// gateNodeWrite — то же для мутаций уровня ноды на конкретный гипервизор.
func gateNodeWrite(t *pve.Tenant, host, node string) error {
if !t.Config().NodeWriteAllowed(host, node) {
return fmt.Errorf("proxmox: write to node %q on host %q not allowed (read_only or allowlist.nodes)", node, host)
}
return nil
}
// pretty форматирует raw JSON для отдачи модели (читабельно).
func pretty(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return "(no data)"
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
return string(raw)
}
return buf.String()
}
// upidMsg собирает человекочитаемое сообщение мутации (с UPID, если есть).
func upidMsg(action, target string, upid string) string {
if upid != "" {
return fmt.Sprintf("%s %s queued (UPID: %s)\nUse task_status (wait=true) to confirm completion.", action, target, upid)
}
return fmt.Sprintf("%s %s done", action, target)
}