Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// backup.go — бэкапы (vzdump): list (read) и create (async UPID).
|
||||
// В v1 НЕ реализованы restore/delete/prune: это тяжёлые необратимые
|
||||
// операции, требующие выверенной семантики; их лучше сделать отдельным
|
||||
// модулем-уточнением, чем рисковать в базовом. (см. README «Roadmap».)
|
||||
|
||||
func registerBackupTools(s *mcp.Server) {
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "backup_list",
|
||||
Description: "List recent backup (vzdump) tasks. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name (empty = whole cluster)", false),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, backupListHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "backup_create",
|
||||
Description: "Create a backup (vzdump) of a VM/CT. Async — returns UPID; poll via task_status(wait). Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM/CT ID", true),
|
||||
"storage": strProps("Backup storage (default: node local)", false),
|
||||
"mode": strProps("Backup mode", true, "snapshot", "suspend", "stop"),
|
||||
"compress": strProps("Compression", false, "zstd", "gzip", "lzo", "none"),
|
||||
"kind": strProps("Guest type", false, "qemu", "lxc"),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, backupCreateHandler)
|
||||
}
|
||||
|
||||
func backupListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.BackupList(cctx, host, getString(args, "node", ""))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func backupCreateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "backup_create"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
kind := guestKind(args)
|
||||
// mode: snapshot/suspend/stop. Если задан storage/compress — кладём в form.
|
||||
form := url.Values{"mode": {getString(args, "mode", "snapshot")}}
|
||||
if s := getString(args, "storage", ""); s != "" {
|
||||
form.Set("storage", s)
|
||||
}
|
||||
if c := getString(args, "compress", ""); c != "" && c != "none" {
|
||||
form.Set("compress", c)
|
||||
}
|
||||
upid, err := t.BackupCreate(ctx, host, node, kind, vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("backup_create", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// cluster.go — read-only инструменты кластера/нод (наблюдение, без мутаций).
|
||||
// Здесь НЕТ node_reboot/shutdown: в однонодовом homelab рестарт хоста
|
||||
// обрушил бы всё окружение (blast-radius при нулевой ценности).
|
||||
|
||||
func registerClusterTools(s *mcp.Server) {
|
||||
// clusters_list — перечень настроенных кластеров (операторских) + версия.
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "clusters_list",
|
||||
Description: "List configured Proxmox clusters (host aliases) and their PVE version. Read-only.",
|
||||
InputSchema: schema(nil, nil),
|
||||
}, clustersListHandler)
|
||||
|
||||
// cluster_status — состояние кластера (quorum, узлы, версии).
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "cluster_status",
|
||||
Description: "Get cluster health: quorum, node list, versions. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, clusterStatusHandler)
|
||||
|
||||
// nodes_list — список нод кластера.
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "nodes_list",
|
||||
Description: "List all nodes in the cluster with status and resource usage. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, nodesListHandler)
|
||||
|
||||
// node_status — детальное состояние ноды.
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "node_status",
|
||||
Description: "Get detailed status (CPU/memory/disk/uptime) of one node. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node"}),
|
||||
}, nodeStatusHandler)
|
||||
|
||||
// node_network — сетевые интерфейсы ноды (мосты/eth).
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "node_network",
|
||||
Description: "List network interfaces and bridges of a node. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node"}),
|
||||
}, nodeNetworkHandler)
|
||||
}
|
||||
|
||||
func clustersListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
var aliases []string
|
||||
for _, h := range t.Config().Hosts {
|
||||
aliases = append(aliases, h.Alias)
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
ver, err := t.Version(cctx, "")
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("Clusters configured: %s\nPVE version: unavailable (%s)", strings.Join(aliases, ", "), err)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Clusters configured: %s\nPVE version: %s",
|
||||
strings.Join(aliases, ", "), pretty(ver))), nil
|
||||
}
|
||||
|
||||
func clusterStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, requestArgs(req))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.ClusterStatus(cctx, host)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func nodesListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, requestArgs(req))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.Nodes(cctx, host)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func nodeStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.NodeStatus(cctx, host, node)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func nodeNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.NodeNetwork(cctx, host, node)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// guest.go — взаимодействие с гостем через QEMU guest-agent.
|
||||
// Сознательно ограничено: только guest_ips (read-only обнаружение IP через
|
||||
// agent). Исполнение команд ВНУТРИ гостя (guest_exec) НЕ реализуем —
|
||||
// это зона ssh__run (remote exec), а не гипервизорного домена
|
||||
// (анти-дубликат). QEMU guest-agent работает только для QEMU-ВМ с agent=1.
|
||||
|
||||
func registerGuestTools(s *mcp.Server) {
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "guest_ips",
|
||||
Description: "Get guest IP addresses via QEMU guest-agent (requires agent=1 and running VM). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, guestIPsHandler)
|
||||
}
|
||||
|
||||
func guestIPsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.GuestIPs(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// lxc.go — LXC-контейнеры: чтение (list/describe/config) + lifecycle
|
||||
// (start/stop/reboot/shutdown), clone, delete, config_update, resize.
|
||||
// Всё по аналогии с QEMU, но через /lxc. Мутации = gate + confirm + probe.
|
||||
|
||||
const vmTypeLXC = "lxc"
|
||||
|
||||
func registerLXCTools(s *mcp.Server) {
|
||||
// --- read ---
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "containers_list",
|
||||
Description: "List LXC containers across the cluster. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, containersListHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "container_describe",
|
||||
Description: "Describe one LXC container: status + config. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("CT ID", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, containerDescribeHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "container_config",
|
||||
Description: "Get the raw LXC config of a container. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("CT ID", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, containerConfigHandler)
|
||||
|
||||
// --- lifecycle ---
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_start",
|
||||
Description: "Start an LXC container. Requires write permission.",
|
||||
InputSchema: containerActionSchema(false),
|
||||
}, vmPatterns, containerStartHandler)
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_stop",
|
||||
Description: "Stop an LXC container. Requires confirm + write permission.",
|
||||
InputSchema: containerActionSchema(true),
|
||||
}, vmPatterns, containerStopHandler)
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_reboot",
|
||||
Description: "Reboot an LXC container. Requires confirm + write permission.",
|
||||
InputSchema: containerActionSchema(true),
|
||||
}, vmPatterns, containerRebootHandler)
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_shutdown",
|
||||
Description: "Gracefully shut down an LXC container. Requires confirm + write permission.",
|
||||
InputSchema: containerActionSchema(true),
|
||||
}, vmPatterns, containerShutdownHandler)
|
||||
|
||||
// --- clone / delete / config / resize ---
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_clone",
|
||||
Description: "Clone an LXC container/template into a new CT ID. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("Source CT ID", true),
|
||||
"newid": intProps("New CT ID (0 = next free)", false),
|
||||
"name": strProps("Name for the clone", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, containerCloneHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_delete",
|
||||
Description: "Permanently delete an LXC container. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("CT ID", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, containerDeleteHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_config_update",
|
||||
Description: "Update safe LXC config fields (hostname, memory, swap, cores, unprivileged). Denied keys rejected. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("CT ID", true),
|
||||
"updates": objectProps("Object of key->value config fields", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "updates", "confirm"}),
|
||||
}, vmPatterns, containerConfigUpdateHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "container_resize",
|
||||
Description: "Resize a rootfs/mountpoint of an LXC container. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("CT ID", true),
|
||||
"disk": strProps("Disk to resize (e.g. rootfs)", true),
|
||||
"size": strProps("Size change, e.g. +5G", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "disk", "size", "confirm"}),
|
||||
}, vmPatterns, containerResizeHandler)
|
||||
}
|
||||
|
||||
// containerActionSchema — схема для lifecycle LXC (с/без confirm).
|
||||
func containerActionSchema(withConfirm bool) map[string]any {
|
||||
props := map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("CT ID", true),
|
||||
}
|
||||
req := []string{"node", "vmid"}
|
||||
if withConfirm {
|
||||
props["confirm"] = strProps("Set to \"true\" to confirm", true, "true")
|
||||
req = append(req, "confirm")
|
||||
}
|
||||
return schema(props, req)
|
||||
}
|
||||
|
||||
// --- read ---
|
||||
|
||||
func containersListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, requestArgs(req))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.GuestResources(cctx, host, vmTypeLXC)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func containerDescribeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
status, err := t.ContainerStatus(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cfg, err := t.ContainerConfig(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult("=== Status ===\n" + pretty(status) + "\n\n=== Config ===\n" + pretty(cfg)), nil
|
||||
}
|
||||
|
||||
func containerConfigHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.ContainerConfig(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
// --- lifecycle ---
|
||||
|
||||
func containerStartHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return containerActionHandler(ctx, req, "start", false)
|
||||
}
|
||||
func containerStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return containerActionHandler(ctx, req, "stop", true)
|
||||
}
|
||||
func containerRebootHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return containerActionHandler(ctx, req, "reboot", true)
|
||||
}
|
||||
func containerShutdownHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return containerActionHandler(ctx, req, "shutdown", true)
|
||||
}
|
||||
|
||||
func containerActionHandler(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if needConfirm {
|
||||
if err := confirm(args, "container_"+action); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.ContainerAction(ctx, host, node, vmid, action)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("container_"+action, fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
func containerCloneHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, src, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "container_clone"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, src); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
newID := getInt(args, "newid", 0)
|
||||
if newID == 0 {
|
||||
data, err := t.NextID(ctx, host)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
newID = intFromData(data)
|
||||
}
|
||||
form := url.Values{"newid": {strconv.Itoa(newID)}}
|
||||
if name := getString(args, "name", ""); name != "" {
|
||||
form.Set("name", name)
|
||||
}
|
||||
upid, err := t.ContainerClone(ctx, host, node, src, newID, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("container_clone", fmt.Sprintf("%d -> %d", src, newID), upid)), nil
|
||||
}
|
||||
|
||||
func containerDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "container_delete"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.ContainerDelete(ctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("container_delete", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
func containerConfigUpdateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "container_config_update"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
updates := getObject(args, "updates")
|
||||
if len(updates) == 0 {
|
||||
return errorResult("'updates' must be a non-empty object"), nil
|
||||
}
|
||||
form := make(url.Values, len(updates))
|
||||
for k, v := range updates {
|
||||
if t.Config().DenyConfigKey(k) {
|
||||
return errorResult(fmt.Sprintf("field %q is denied by policy", k)), nil
|
||||
}
|
||||
form.Set(k, fmt.Sprint(v))
|
||||
}
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeLXC, vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("container_config_update", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
func containerResizeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "container_resize"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
disk, err := requireName(args, "disk")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
size := getString(args, "size", "")
|
||||
if size == "" {
|
||||
return errorResult("'size' is required (e.g. +5G)"), nil
|
||||
}
|
||||
upid, err := t.ResizeDisk(ctx, host, node, vmTypeLXC, vmid, url.Values{"disk": {disk}, "size": {size}})
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("container_resize", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// patterns.go — доменные forge-probe эмиттеры proxmox-модуля. Механика probe
|
||||
// (wrapProbe/registerPatternTool/isProbe, флаг поддержки) — в forge-toolkit.
|
||||
//
|
||||
// Тулы эмитируют КАНОНИЧЕСКИЙ паттерн ресурса. ВАЖНО: паттерн первым
|
||||
// компонентом несёт `host=<alias>` — только так permission-правила оператора
|
||||
// могут различить один и тот же vmid/node на разных гипервизорах (коллизия
|
||||
// VMID). Пример правила:
|
||||
//
|
||||
// patterns:
|
||||
// - match: "proxmox__vm_delete"
|
||||
// pattern: "host=pve1 node=pve vmid=500"
|
||||
// then: ask
|
||||
//
|
||||
// Для мутаций always НЕ эмитим: каждый деструктивный вызов — отдельный ask
|
||||
// (никакого авто-одобрения по префиксу без явного allow у оператора).
|
||||
|
||||
// vmPatterns — паттерн ресурса для VM/CT-операции: host + node + vmid.
|
||||
func vmPatterns(args map[string]any) (patterns, always []string) {
|
||||
var b strings.Builder
|
||||
b.WriteString("host=" + getString(args, "host", ""))
|
||||
if n := getString(args, "node", ""); n != "" {
|
||||
b.WriteString(" node=" + n)
|
||||
}
|
||||
if v := getString(args, "vmid", ""); v != "" {
|
||||
b.WriteString(" vmid=" + v)
|
||||
}
|
||||
return []string{b.String()}, nil
|
||||
}
|
||||
|
||||
// nodePatterns — паттерн для операции уровня ноды: host + node.
|
||||
func nodePatterns(args map[string]any) (patterns, always []string) {
|
||||
p := "host=" + getString(args, "host", "") + " node=" + getString(args, "node", "")
|
||||
return []string{p}, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package tools — MCP-слой forge-tools-proxmox: превращение JSON-RPC
|
||||
// запроса/ответа в вызовы доменного pve.Tenant. Никакой бизнес-логики.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"forge-tools-proxmox/internal/pve"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// RegisterAll регистрирует все инструменты proxmox-сервера. Инструменты
|
||||
// сгруппированы по доменам (см. README): кластер/ноды, VM, конфиг/диски/сеть/
|
||||
// cloud-init, LXC, снапшоты, бэкапы, задачи, гость, метрики, хранилища.
|
||||
func RegisterAll(s *mcp.Server, m *pve.Manager) {
|
||||
SetManager(m)
|
||||
registerClusterTools(s)
|
||||
registerVMTools(s)
|
||||
registerVMConfigTools(s)
|
||||
registerVMDiskTools(s)
|
||||
registerVMNetTools(s)
|
||||
registerVMCloudInitTools(s)
|
||||
registerLXCTools(s)
|
||||
registerSnapshotTools(s)
|
||||
registerBackupTools(s)
|
||||
registerTaskTools(s)
|
||||
registerGuestTools(s)
|
||||
registerStorageTools(s)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// snapshot.go — снапшоты VM/CT: list (read), create, delete, rollback.
|
||||
// Снапшот — точка восстановления гостя; delete/rollback необратимы →
|
||||
// confirm + gate + probe.
|
||||
|
||||
func registerSnapshotTools(s *mcp.Server) {
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "snapshot_list",
|
||||
Description: "List snapshots of a VM/CT. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM/CT ID", true),
|
||||
"kind": strProps("Guest type", false, "qemu", "lxc"),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, snapshotListHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "snapshot_create",
|
||||
Description: "Create a snapshot of a VM/CT. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM/CT ID", true),
|
||||
"name": strProps("Snapshot name", true),
|
||||
"desc": strProps("Snapshot description", false),
|
||||
"vmstate": boolProps("Include running state (RAM). Default: false", false),
|
||||
"kind": strProps("Guest type", false, "qemu", "lxc"),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "name", "confirm"}),
|
||||
}, vmPatterns, snapshotCreateHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "snapshot_delete",
|
||||
Description: "Delete a snapshot. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM/CT ID", true),
|
||||
"name": strProps("Snapshot name", true),
|
||||
"kind": strProps("Guest type", false, "qemu", "lxc"),
|
||||
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
|
||||
}, []string{"node", "vmid", "name", "confirm"}),
|
||||
}, vmPatterns, snapshotDeleteHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "snapshot_rollback",
|
||||
Description: "Roll back a VM/CT to a snapshot (restores disk/state). Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM/CT ID", true),
|
||||
"name": strProps("Snapshot name", true),
|
||||
"kind": strProps("Guest type", false, "qemu", "lxc"),
|
||||
"confirm": strProps("Set to \"true\" to confirm destructive rollback", true, "true"),
|
||||
}, []string{"node", "vmid", "name", "confirm"}),
|
||||
}, vmPatterns, snapshotRollbackHandler)
|
||||
}
|
||||
|
||||
func snapshotListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
kind := guestKind(args)
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.SnapshotList(cctx, host, node, kind, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func snapshotCreateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "snapshot_create"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
name, err := requireName(args, "name")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
form := url.Values{"snapname": {name}}
|
||||
if d := getString(args, "desc", ""); d != "" {
|
||||
form.Set("description", d)
|
||||
}
|
||||
if getBool(args, "vmstate", false) {
|
||||
form.Set("vmstate", "1")
|
||||
}
|
||||
upid, err := t.SnapshotCreate(ctx, host, node, guestKind(args), vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("snapshot_create", fmt.Sprintf("%s/%d %s", node, vmid, name), upid)), nil
|
||||
}
|
||||
|
||||
func snapshotDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "snapshot_delete"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
name, err := requireName(args, "name")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.SnapshotDelete(ctx, host, node, guestKind(args), vmid, name)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("snapshot_delete", fmt.Sprintf("%s/%d %s", node, vmid, name), upid)), nil
|
||||
}
|
||||
|
||||
func snapshotRollbackHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "snapshot_rollback"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
name, err := requireName(args, "name")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.SnapshotRollback(ctx, host, node, guestKind(args), vmid, name)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("snapshot_rollback", fmt.Sprintf("%s/%d %s", node, vmid, name), upid)), nil
|
||||
}
|
||||
|
||||
// guestKind возвращает тип гостя (qemu/lxc) из аргумента, по умолчанию qemu.
|
||||
func guestKind(args map[string]any) string {
|
||||
k := getString(args, "kind", vmTypeQEMU)
|
||||
if k != vmTypeQEMU && k != vmTypeLXC {
|
||||
return vmTypeQEMU
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge-tools-proxmox/internal/pve"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// storage.go — хранилища: storage_list, storage_status (read) и содержимое
|
||||
// (templates_list / isos_list). Только наблюдение: изменение хранилищ
|
||||
// (Ceph/ZFS/SDN) — зона отдельного модуля (см. README «Roadmap»), чтобы не
|
||||
// тащить комбайн и не рисковать дисковой подсистемой.
|
||||
|
||||
func registerStorageTools(s *mcp.Server) {
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "storage_list",
|
||||
Description: "List storage pools (type/content/usage). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, storageListHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "storage_status",
|
||||
Description: "Storage pool status and usage (per node). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node"}),
|
||||
}, storageStatusHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "templates_list",
|
||||
Description: "List LXC templates (vztmpl) on a storage. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"storage": strProps("Storage (default: local)", false),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node"}),
|
||||
}, templatesListHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "isos_list",
|
||||
Description: "List ISO images on a storage. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"storage": strProps("Storage (default: local)", false),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node"}),
|
||||
}, isosListHandler)
|
||||
}
|
||||
|
||||
func storageListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, requestArgs(req))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.StorageList(cctx, host)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func storageStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// storage_status — статус хранилищ ноды (через /nodes/{node}/storage).
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.NodeStorage(cctx, host, node)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func templatesListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return contentListHandler(ctx, req, "vztmpl")
|
||||
}
|
||||
|
||||
func isosListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return contentListHandler(ctx, req, "iso")
|
||||
}
|
||||
|
||||
// contentListHandler — содержимое хранилища по типу (vztmpl/iso/backup/images).
|
||||
func contentListHandler(ctx context.Context, req *mcp.CallToolRequest, contentType string) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
storage := getString(args, "storage", "local")
|
||||
if err := pve.ValidateIdentifier(storage); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.StorageContent(cctx, host, node, storage, contentType)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// task.go — задачи Proxmox (UPID): list (read), status (read, с опц. wait),
|
||||
// log (read), stop (confirm + write). Мутации возвращают UPID, поэтому
|
||||
// task_status(wait=true) — ключ к подтверждению async-операций, при этом
|
||||
// poll строго ограничен timeout (bounded §9.6).
|
||||
|
||||
func registerTaskTools(s *mcp.Server) {
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "tasks_list",
|
||||
Description: "List recent cluster/node tasks. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name (empty = whole cluster)", false),
|
||||
"limit": intProps("Number of tasks (default: 20)", false),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, tasksListHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "task_status",
|
||||
Description: "Get status of a task by UPID. Set wait=true to poll until it finishes (bounded by task_poll_max_sec). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"upid": strProps("Task UPID", true),
|
||||
"wait": boolProps("Wait for completion (default: false)", false),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "upid"}),
|
||||
}, taskStatusHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "task_log",
|
||||
Description: "Get the log of a task by UPID. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"upid": strProps("Task UPID", true),
|
||||
"limit": intProps("Number of lines (default: 50)", false),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "upid"}),
|
||||
}, taskLogHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "task_stop",
|
||||
Description: "Stop/cancel a running task by UPID. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"upid": strProps("Task UPID", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "upid", "confirm"}),
|
||||
}, nodePatterns, taskStopHandler)
|
||||
}
|
||||
|
||||
func tasksListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
limit := getInt(args, "limit", 20)
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.TasksList(cctx, host, getString(args, "node", ""), limit)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func taskStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := requireUPID(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if getBool(args, "wait", false) {
|
||||
out, err := t.TaskWait(ctx, host, node, upid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(out), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.TaskStatus(cctx, host, node, upid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func taskLogHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := requireUPID(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
limit := getInt(args, "limit", 50)
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.TaskLog(cctx, host, node, upid, limit)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func taskStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, err := requireNode(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := requireUPID(args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, args)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := confirm(args, "task_stop"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
// task_stop — отмена выполняющейся задачи (не ресурс кластера).
|
||||
if err := gateNodeWrite(t, host, node); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upidMsg2, err := t.TaskStop(ctx, host, node, upid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("task_stop", fmt.Sprintf("%s %s", node, upid), upidMsg2)), nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"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 objectProps(desc string, required bool) map[string]any {
|
||||
return toolkit.ObjectProps(desc, required)
|
||||
}
|
||||
|
||||
func textResult(text string) *mcp.CallToolResult {
|
||||
return toolkit.Text(text)
|
||||
}
|
||||
|
||||
func errorResult(msg string) *mcp.CallToolResult {
|
||||
return toolkit.Error(msg)
|
||||
}
|
||||
|
||||
// patternsFn — forge-probe эмиттер паттернов (см. toolkit.PatternsFn).
|
||||
type patternsFn = toolkit.PatternsFn
|
||||
|
||||
func registerPatternTool(s *mcp.Server, tool *mcp.Tool, fn patternsFn, h mcp.ToolHandler) {
|
||||
toolkit.RegisterPatternTool(s, tool, fn, h)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"forge-tools-proxmox/internal/pve"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// vm.go — QEMU-ВМ: чтение (list/describe/config/nextid) и lifecycle
|
||||
// (start/stop/reboot/shutdown/suspend/resume) + clone/delete/convert-template.
|
||||
// Мутации за гейтом (read_only + allowlist) и registerPatternTool (probe →
|
||||
// require_approval). Удаление/конвертация — дополнительно confirm.
|
||||
//
|
||||
// ВАЖНО (анти-дубликат): запуск ВМ НЕ через guest/консоль — это зона ssh__run.
|
||||
// Здесь только управление гостевой сущностью гипервизора.
|
||||
|
||||
// vmType — тип гостя в PVE API ("qemu"|"lxc"). Константа для единообразия.
|
||||
const vmTypeQEMU = "qemu"
|
||||
|
||||
func registerVMTools(s *mcp.Server) {
|
||||
// --- read ---
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "vms_list",
|
||||
Description: "List QEMU VMs across the cluster (status, resources). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, vmsListHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "vm_describe",
|
||||
Description: "Describe one VM: current status + config (cores, memory, disks, network). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, vmDescribeHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "vm_config",
|
||||
Description: "Get the raw QEMU config of a VM (qm config). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, vmConfigHandler)
|
||||
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "vm_next_id",
|
||||
Description: "Get the next free VMID in the cluster. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"host": strProps("Cluster alias (default: primary)", false),
|
||||
}, nil),
|
||||
}, vmNextIDHandler)
|
||||
|
||||
// --- lifecycle (мутации ⇒ write gate + probe/approval) ---
|
||||
s.AddTool(&mcp.Tool{
|
||||
Name: "vm_start",
|
||||
Description: "Start a VM. Requires write permission (allowlist + approve).",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
}, []string{"node", "vmid"}),
|
||||
}, vmStartHandler)
|
||||
|
||||
for _, a := range []struct {
|
||||
name string
|
||||
description string
|
||||
confirm bool
|
||||
handler mcp.ToolHandler
|
||||
}{
|
||||
{"vm_stop", "Force-stop a VM. Requires confirm + write permission.", true, vmStopHandler},
|
||||
{"vm_reboot", "Reboot a VM. Requires confirm + write permission.", true, vmRebootHandler},
|
||||
{"vm_shutdown", "Gracefully shut down a VM (guest agent / ACPI). Requires confirm + write permission.", true, vmShutdownHandler},
|
||||
{"vm_suspend", "Suspend (pause) a VM. Requires write permission.", false, vmSuspendHandler},
|
||||
{"vm_resume", "Resume a VM. Requires write permission.", false, vmResumeHandler},
|
||||
} {
|
||||
props := map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
}
|
||||
required := []string{"node", "vmid"}
|
||||
if a.confirm {
|
||||
props["confirm"] = strProps("Set to \"true\" to confirm this destructive/lifecycle action", true, "true")
|
||||
required = append(required, "confirm")
|
||||
}
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: a.name,
|
||||
Description: a.description,
|
||||
InputSchema: schema(props, required),
|
||||
}, vmPatterns, a.handler)
|
||||
}
|
||||
|
||||
// clone / delete / template
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_clone",
|
||||
Description: "Clone a VM (template) into a new VMID. If a template, clones it. Requires write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("Source VM/template ID", true),
|
||||
"newid": intProps("New VMID (0 = next free)", false),
|
||||
"name": strProps("Name for the clone", false),
|
||||
"full": boolProps("Full clone (independent copy metadata). Default: depends on template", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm creating a new guest", true, "true"),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, vmCloneHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_delete",
|
||||
Description: "Permanently delete a VM. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"purge": boolProps("Also remove from backup jobs/HA/DR (default: true)", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
|
||||
"force": boolProps("Force even if protected", false),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, vmDeleteHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_convert_template",
|
||||
Description: "Convert a VM into a template. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm conversion", true, "true"),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, vmConvertTemplateHandler)
|
||||
}
|
||||
|
||||
// --- read handlers ---
|
||||
|
||||
func vmsListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, requestArgs(req))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.GuestResources(cctx, host, vmTypeQEMU)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func vmDescribeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
status, err := t.VMStatus(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cfg, err := t.VMConfig(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult("=== Status ===\n" + pretty(status) + "\n\n=== Config ===\n" + pretty(cfg)), nil
|
||||
}
|
||||
|
||||
func vmConfigHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.VMConfig(cctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
func vmNextIDHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
host, err := resolveHost(t, requestArgs(req))
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
cctx, cancel := timeout(ctx, t)
|
||||
defer cancel()
|
||||
data, err := t.NextID(cctx, host)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(pretty(data)), nil
|
||||
}
|
||||
|
||||
// --- lifecycle handlers ---
|
||||
|
||||
func vmStartHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return vmActionHandler(ctx, req, "start", false)
|
||||
}
|
||||
func vmStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return vmActionHandler(ctx, req, "stop", true)
|
||||
}
|
||||
func vmRebootHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return vmActionHandler(ctx, req, "reboot", true)
|
||||
}
|
||||
func vmShutdownHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return vmActionHandler(ctx, req, "shutdown", true)
|
||||
}
|
||||
func vmSuspendHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return vmActionHandler(ctx, req, "suspend", false)
|
||||
}
|
||||
func vmResumeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return vmActionHandler(ctx, req, "resume", false)
|
||||
}
|
||||
|
||||
// vmActionHandler — общий обработчик lifecycle: gateVMWrite + (confirm) +
|
||||
// POST /status/<action> → UPID.
|
||||
func vmActionHandler(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if needConfirm {
|
||||
if err := confirm(args, "vm_"+action); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.VMAction(ctx, host, node, vmid, action, nil)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_"+action, fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
func vmCloneHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, src, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_clone"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
// newid: 0 = next free; при клоне всегда гейт по allowlist на источник.
|
||||
if err := gateVMWrite(t, host, src); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
newID := getInt(args, "newid", 0)
|
||||
if newID == 0 {
|
||||
nid, err := t.NextID(ctx, host)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
newID = intFromData(nid)
|
||||
}
|
||||
form := url.Values{"newid": {strconv.Itoa(newID)}}
|
||||
if name := getString(args, "name", ""); name != "" {
|
||||
form.Set("name", name)
|
||||
}
|
||||
// full: независимый клон; для template обычно 1, для машин задаёт агент.
|
||||
if getBool(args, "full", false) {
|
||||
form.Set("full", "1")
|
||||
}
|
||||
upid, err := t.VMClone(ctx, host, node, src, newID, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_clone", fmt.Sprintf("%d -> %d", src, newID), upid)), nil
|
||||
}
|
||||
|
||||
func vmDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_delete"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.VMDelete(ctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_delete", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
func vmConvertTemplateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_convert_template"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.VMConvertTemplate(ctx, host, node, vmid)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_convert_template", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
// resolveVMArgs извлекает и валидирует node/vmid/host; возвращает errorResult
|
||||
// как *mcp.CallToolResult при ошибке (r != nil). Host резолвится с учётом
|
||||
// мульти-гипервизора (при >1 хосте обязателен — иначе коллизия VMID).
|
||||
func resolveVMArgs(t *pve.Tenant, args map[string]any) (node string, vmid int, host string, r *mcp.CallToolResult) {
|
||||
var err error
|
||||
if node, err = requireNode(args); err != nil {
|
||||
return "", 0, "", errorResult(err.Error())
|
||||
}
|
||||
if vmid, err = requireVMID(args); err != nil {
|
||||
return "", 0, "", errorResult(err.Error())
|
||||
}
|
||||
if host, err = resolveHost(t, args); err != nil {
|
||||
return "", 0, "", errorResult(err.Error())
|
||||
}
|
||||
return node, vmid, host, nil
|
||||
}
|
||||
|
||||
// intFromData вытаскивает число из json-ответа (nextid / id): {"data": "101"}.
|
||||
func intFromData(data json.RawMessage) int {
|
||||
var obj struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return 0
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(obj.Data, &s); err == nil {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(obj.Data, &f); err == nil {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// vm_cloudinit.go — настройка cloud-init гостя (только QEMU): ciuser,
|
||||
// cipassword, ipconfig0/1.., sshkeys. Структурная мутация → confirm + gate.
|
||||
// Для гостей-ВМ; LXC-cloud-init не поддерживается PVE API.
|
||||
|
||||
func registerVMCloudInitTools(s *mcp.Server) {
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_set_cloudinit",
|
||||
Description: "Set cloud-init options of a QEMU VM: user, password, per-NIC ipconfig, SSH keys. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"ciuser": strProps("Cloud-init login user", false),
|
||||
"cipassword": strProps("Cloud-init login password", false),
|
||||
"sshkeys": strProps("Public SSH key(s) for the user", false),
|
||||
"ipconfig0": strProps("ipconfig0 spec, e.g. ip=dhcp or ip=192.168.1.10/24,gw=192.168.102.100", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "confirm"}),
|
||||
}, vmPatterns, vmSetCloudInitHandler)
|
||||
}
|
||||
|
||||
func vmSetCloudInitHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_set_cloudinit"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
form := url.Values{}
|
||||
for _, k := range []string{"ciuser", "cipassword", "sshkeys", "ipconfig0"} {
|
||||
if v := getString(args, k, ""); v != "" {
|
||||
form.Set(k, v)
|
||||
}
|
||||
}
|
||||
if len(form) == 0 {
|
||||
return errorResult("at least one cloud-init field is required"), nil
|
||||
}
|
||||
// Включаем cloud-init диск (для OVMF/машин с cidata) — обычно ide2:cloudinit.
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_set_cloudinit", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// vm_config.go — изменение конфига ВМ (vm_config_update): НЕ структурные
|
||||
// поля (name/cpu/core/memory/balloon/vcpus ...), безопасные для общего update.
|
||||
// Структурно-опасные ключи (delete/revert/hotplug/sockets/cores...) —
|
||||
// в pve.DefaultDenyConfigKeys, туда же отправляем через выделенные
|
||||
// инструменты (disk/net) с confirm, а не через общий update.
|
||||
//
|
||||
// Принимает неструктурированный объект `updates` (map key→value) — модель
|
||||
// собирает нужные ей поля; guard отклоняет запрещённые.
|
||||
|
||||
func registerVMConfigTools(s *mcp.Server) {
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_config_update",
|
||||
Description: "Update safe (non-structural) QEMU config fields of a VM: name, cpu, cores, memory, balloon, vcpus, tags, description. Structural/denied keys are rejected. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"updates": objectProps("Object of key->value config fields to set", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "updates", "confirm"}),
|
||||
}, vmPatterns, vmConfigUpdateHandler)
|
||||
}
|
||||
|
||||
func vmConfigUpdateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_config_update"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
updates := getObject(args, "updates")
|
||||
if len(updates) == 0 {
|
||||
return errorResult("'updates' must be a non-empty object of config fields"), nil
|
||||
}
|
||||
form := make(url.Values, len(updates))
|
||||
for k, v := range updates {
|
||||
if t.Config().DenyConfigKey(k) {
|
||||
return errorResult(fmt.Sprintf("field %q is denied by policy (use a dedicated tool)", k)), nil
|
||||
}
|
||||
form.Set(k, fmt.Sprint(v))
|
||||
}
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_config_update", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||||
}
|
||||
|
||||
// getObject — читает объект аргумента (map[string]any).
|
||||
func getObject(args map[string]any, key string) map[string]any {
|
||||
if v, ok := args[key].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"forge-tools-proxmox/internal/pve"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// vm_disk.go — операции над дисками ВМ через выделенные эндпоинты
|
||||
// (/resize, /move_disk) или конфиг (добавить/удалить). Все — структурные
|
||||
// мутации: require write gate + confirm + registerPatternTool (probe).
|
||||
// Для QEMU имена дисков: scsi0.., virtio0.., ide0.., sata0...
|
||||
|
||||
func registerVMDiskTools(s *mcp.Server) {
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_resize_disk",
|
||||
Description: "Resize a VM disk by name. Size like '+5G' or absolute. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"disk": strProps("Disk device (e.g. scsi0)", true),
|
||||
"size": strProps("Size change, e.g. +5G or 20G", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "disk", "size", "confirm"}),
|
||||
}, vmPatterns, vmResizeDiskHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_add_disk",
|
||||
Description: "Add a new disk (scsi/virtio/ide/sata slot) to a VM. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"storage": strProps("Storage name (e.g. local-lvm)", true),
|
||||
"size_gb": intProps("Disk size in GB", true),
|
||||
"iface": strProps("Slot to use (e.g. scsi1); required to avoid overwriting", false),
|
||||
"ssd": boolProps("Mark as SSD (default: false)", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "storage", "size_gb", "confirm"}),
|
||||
}, vmPatterns, vmAddDiskHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_remove_disk",
|
||||
Description: "Remove a disk from a VM (permanently! frees the storage). Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"disk": strProps("Disk device to remove (e.g. scsi1)", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm permanent removal", true, "true"),
|
||||
}, []string{"node", "vmid", "disk", "confirm"}),
|
||||
}, vmPatterns, vmRemoveDiskHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_move_disk",
|
||||
Description: "Move a VM disk to another storage (storage migration). Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"disk": strProps("Disk to move (e.g. scsi0)", true),
|
||||
"storage": strProps("Destination storage", true),
|
||||
"delete": boolProps("Delete the source after successful move (default: false)", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "disk", "storage", "confirm"}),
|
||||
}, vmPatterns, vmMoveDiskHandler)
|
||||
}
|
||||
|
||||
func vmResizeDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_resize_disk"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
disk, err := requireName(args, "disk")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
size := getString(args, "size", "")
|
||||
if size == "" {
|
||||
return errorResult("'size' is required (e.g. +5G or 20G)"), nil
|
||||
}
|
||||
form := url.Values{"disk": {disk}, "size": {size}}
|
||||
upid, err := t.ResizeDisk(ctx, host, node, vmTypeQEMU, vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_resize_disk", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
|
||||
}
|
||||
|
||||
func vmAddDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_add_disk"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
storage := getString(args, "storage", "")
|
||||
sizeGB := getInt(args, "size_gb", 0)
|
||||
if storage == "" || sizeGB <= 0 {
|
||||
return errorResult("'storage' and positive 'size_gb' are required"), nil
|
||||
}
|
||||
iface := getString(args, "iface", "")
|
||||
if iface == "" {
|
||||
// Требуем явный слот: авто-выбор рискует перезаписать существующий
|
||||
// диск (например scsi0). Fail-closed.
|
||||
return errorResult("'iface' is required (e.g. scsi1) to avoid overwriting an existing disk"), nil
|
||||
}
|
||||
if err := pve.ValidateIdentifier(iface); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
value := fmt.Sprintf("%s:%d", storage, sizeGB)
|
||||
if getBool(args, "ssd", false) {
|
||||
value += ",ssd=1"
|
||||
}
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{iface: {value}})
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_add_disk", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
|
||||
}
|
||||
|
||||
func vmRemoveDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_remove_disk"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
disk, err := requireName(args, "disk")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
// Удаление диска = конфиг UPDATE с delete=<disk> (PVE-семантика).
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{"delete": {disk}})
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_remove_disk", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
|
||||
}
|
||||
|
||||
func vmMoveDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_move_disk"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
disk, err := requireName(args, "disk")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
storage := getString(args, "storage", "")
|
||||
if storage == "" {
|
||||
return errorResult("'storage' is required"), nil
|
||||
}
|
||||
form := url.Values{"disk": {disk}, "storage": {storage}}
|
||||
if getBool(args, "delete", false) {
|
||||
form.Set("delete", "1")
|
||||
}
|
||||
upid, err := t.MoveDisk(ctx, host, node, vmid, form)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_move_disk", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// vm_net.go — сетевые интерфейсы ВМ (net0..netN): добавить/обновить/удалить.
|
||||
// Это структурные изменения конфига → write gate + confirm + probe. Модель
|
||||
// задаёт iface (net0), модель/бридж; guard валидирует имя интерфейса.
|
||||
|
||||
func registerVMNetTools(s *mcp.Server) {
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_add_network",
|
||||
Description: "Add a network interface (netN) to a VM, e.g. net0 via vmbr0. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"iface": strProps("Interface slot (e.g. net0)", true),
|
||||
"model": strProps("NIC model", true, "virtio", "e1000", "vmxnet3", "rtl8139"),
|
||||
"bridge": strProps("Bridge (e.g. vmbr0)", true),
|
||||
"mac": strProps("MAC (empty = auto)", false),
|
||||
"firewall": boolProps("Enable firewall (default: true)", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "iface", "bridge", "confirm"}),
|
||||
}, vmPatterns, vmAddNetworkHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_update_network",
|
||||
Description: "Update an existing network interface (model/bridge/mac/firewall). Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"iface": strProps("Interface slot (e.g. net0)", true),
|
||||
"model": strProps("NIC model", false, "virtio", "e1000", "vmxnet3", "rtl8139"),
|
||||
"bridge": strProps("Bridge (e.g. vmbr0)", false),
|
||||
"mac": strProps("MAC address", false),
|
||||
"firewall": boolProps("Enable firewall (default: true)", false),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "iface", "confirm"}),
|
||||
}, vmPatterns, vmUpdateNetworkHandler)
|
||||
|
||||
registerPatternTool(s, &mcp.Tool{
|
||||
Name: "vm_remove_network",
|
||||
Description: "Remove a network interface from a VM. Requires confirm + write permission.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"node": strProps("Node name", true),
|
||||
"vmid": strProps("VM ID", true),
|
||||
"iface": strProps("Interface slot (e.g. net0)", true),
|
||||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||||
}, []string{"node", "vmid", "iface", "confirm"}),
|
||||
}, vmPatterns, vmRemoveNetworkHandler)
|
||||
}
|
||||
|
||||
func vmAddNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_add_network"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
iface, err := requireName(args, "iface")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
model := getString(args, "model", "virtio")
|
||||
bridge := getString(args, "bridge", "")
|
||||
if bridge == "" {
|
||||
return errorResult("'bridge' is required"), nil
|
||||
}
|
||||
value := fmt.Sprintf("%s,bridge=%s", model, bridge)
|
||||
if mac := getString(args, "mac", ""); mac != "" {
|
||||
value += ",mac=" + mac
|
||||
}
|
||||
if getBool(args, "firewall", true) {
|
||||
value += ",firewall=1"
|
||||
}
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{iface: {value}})
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_add_network", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
|
||||
}
|
||||
|
||||
func vmUpdateNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_update_network"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
iface, err := requireName(args, "iface")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
// update перезаписывает весь сетевой спецификатор: собираем из явных полей.
|
||||
parts := []string{getString(args, "model", "virtio")}
|
||||
if b := getString(args, "bridge", ""); b != "" {
|
||||
parts = append(parts, "bridge="+b)
|
||||
}
|
||||
if m := getString(args, "mac", ""); m != "" {
|
||||
parts = append(parts, "mac="+m)
|
||||
}
|
||||
if getBool(args, "firewall", true) {
|
||||
parts = append(parts, "firewall=1")
|
||||
}
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{iface: {strings.Join(parts, ",")}})
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_update_network", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
|
||||
}
|
||||
|
||||
func vmRemoveNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t, err := tenantFor(ctx, req)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
args := requestArgs(req)
|
||||
node, vmid, host, r := resolveVMArgs(t, args)
|
||||
if r != nil {
|
||||
return r, nil
|
||||
}
|
||||
if err := confirm(args, "vm_remove_network"); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
iface, err := requireName(args, "iface")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{"delete": {iface}})
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(upidMsg("vm_remove_network", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user