Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user