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/ → 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 }