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