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
+90
View File
@@ -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
}
+161
View File
@@ -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
}
+44
View File
@@ -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
}
+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)
}
+354
View File
@@ -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
}
+40
View File
@@ -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
}
+28
View File
@@ -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)
}
+181
View File
@@ -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
}
+131
View File
@@ -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
}
+172
View File
@@ -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
}
+64
View File
@@ -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)
}
+390
View File
@@ -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
}
+62
View File
@@ -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
}
+73
View File
@@ -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
}
+204
View File
@@ -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
}
+161
View File
@@ -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
}