@aidalinfo/proxmox-mcp 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  MCP server for a Proxmox VE cluster, with two modes:
4
4
 
5
5
  - **`readonly`** (default) — only read tools are exposed.
6
- - **`full`** — read tools **plus** write tools (VM/LXC lifecycle, snapshots, migrate).
6
+ - **`full`** — read tools **plus** write tools (VM/LXC lifecycle, snapshots, migrate, typed VM provisioning).
7
7
 
8
8
  In `readonly` mode the write tools are never registered, so the model cannot call them.
9
9
 
@@ -14,7 +14,64 @@ In `readonly` mode the write tools are never registered, so the model cannot cal
14
14
  `pve_container_info`, `pve_storage`, `pve_storage_content`, `pve_tasks`, `pve_raw_get`.
15
15
 
16
16
  **Write (`full` only):** `pve_vm_action`, `pve_lxc_action`, `pve_snapshot_create`,
17
- `pve_snapshot_delete`, `pve_snapshot_rollback`, `pve_migrate`.
17
+ `pve_snapshot_delete`, `pve_snapshot_rollback`, `pve_migrate`, `pve_vm_create`, `pve_vm_configure`,
18
+ `pve_vm_delete`.
19
+
20
+ ## VM provisioning (`full` only)
21
+
22
+ Three typed tools let an internal operation, for example the Bot Factory, provision a QEMU VM. None of them
23
+ takes an HTTP method, a raw API path or free Proxmox parameters. Every field is typed, bounded and mapped
24
+ by the tool, and unknown properties are refused (strict schemas). There is no generic raw
25
+ `POST`/`PUT`/`DELETE`. Approval stays in Pulse Chat.
26
+
27
+ | Tool | API | Checks before | Verified after |
28
+ |---|---|---|---|
29
+ | `pve_vm_create` | `POST /nodes/{sourceNode}/qemu/{templateVmid}/clone` | the source is a **template**; the target node exists and is online; the VMID is **free cluster-wide** (never overwrites) | the clone task ends OK; the new VM exists on the target node with the requested name |
30
+ | `pve_vm_configure` | `PUT /nodes/{node}/qemu/{vmid}/config` | exact `expectedName`; not a template, not locked | every key sent is read back; reports changes still pending on a running VM |
31
+ | `pve_vm_delete` (`destructiveHint`) | `DELETE /nodes/{node}/qemu/{vmid}` | exact `expectedName`; VM **stopped** (it is never stopped automatically); not a template, locked or protected; HA-managed only with `purge` | the destroy task ends OK; the VMID no longer exists anywhere in the cluster |
32
+
33
+ `pve_vm_create` takes:
34
+
35
+ - `sourceNode`, `templateVmid`, `targetNode`, `vmid` (100 to 999 999 999) and `name` (a DNS name);
36
+ - `fullClone` (default `true`);
37
+ - optionally `storage` (full clone only), `pool` and `description`.
38
+
39
+ `pve_vm_configure` sends **only the delta**. A VM that already matches gets a no-op and nothing is sent. Allowed
40
+ `changes`:
41
+
42
+ | Field | Proxmox key | Bounds |
43
+ |---|---|---|
44
+ | `name` | `name` | DNS name |
45
+ | `cores`, `sockets` | `cores`, `sockets` | 1–128, 1–8 |
46
+ | `memoryMiB` | `memory` | 256 MiB – 1 TiB |
47
+ | `cpuType` | `cpu` | `host`, `max`, `kvm64`, `qemu64`, `x86-64-v2`, `x86-64-v2-AES`, `x86-64-v3`, `x86-64-v4` |
48
+ | `onboot`, `agent` | `onboot`, `agent` | booleans |
49
+ | `description`, `tags` | `description`, `tags` | 8 KiB without control characters; 20 lowercase tags |
50
+ | `ipConfig0` | `ipconfig0` | `{ ip: "dhcp" \| CIDR, gw?, ip6: "dhcp" \| "auto" \| CIDR, gw6? }` |
51
+ | `nameservers`, `searchDomains` | `nameserver`, `searchdomain` | ≤ 3 IPs, ≤ 6 domain names |
52
+ | `sshPublicKeys` | `sshkeys` | ≤ 10 OpenSSH public keys; replaces the list; only the key type and the last characters are shown back |
53
+
54
+ The following are **not configurable**: `args`, hardware passthrough, disks, network devices, and any other
55
+ Proxmox key. Creating a VM from an ISO, importing or resizing a disk, LXC containers, ACLs, pools and nodes are
56
+ out of scope too.
57
+
58
+ **Tasks are awaited.** Every UPID is validated, and its node is read from the UPID itself. The tool then polls
59
+ `GET /nodes/{taskNode}/tasks/{encodedUpid}/status` until the task stops.
60
+
61
+ - Only `exitstatus === "OK"` counts as a success.
62
+ - The wait is bounded: 30 min for a clone, 10 min for a delete, 2 min for a configuration.
63
+ - On a failure or a timeout, the tool returns the UPID and the last status. A timed-out task may still finish,
64
+ so check `pve_tasks` before retrying.
65
+ - No success is announced before the resource has been read back.
66
+
67
+ Path segments and UPIDs are always percent-encoded, and the token never appears in an error message.
68
+
69
+ The API token needs these privileges:
70
+
71
+ - `VM.Clone` on the template;
72
+ - `VM.Allocate`, `Datastore.AllocateSpace` and `VM.Config.*` on the target;
73
+ - `VM.Allocate` to delete;
74
+ - `Pool.Allocate` when `pool` is used.
18
75
 
19
76
  ## Environment
20
77
 
@@ -2,7 +2,7 @@ import { describe, it, expect } from "@jest/globals";
2
2
  import { registerTools, parseMode } from "../register.js";
3
3
  function fakeServer() {
4
4
  const names = [];
5
- const server = { tool: (name) => { names.push(name); } };
5
+ const server = { tool: (name) => { names.push(name); }, registerTool: (name) => { names.push(name); } };
6
6
  return { server, names };
7
7
  }
8
8
  const fakeClient = {};
@@ -21,6 +21,8 @@ describe("registerTools mode gating", () => {
21
21
  expect(names).not.toContain("pve_vm_action");
22
22
  expect(names).not.toContain("pve_snapshot_create");
23
23
  expect(names).not.toContain("pve_migrate");
24
+ for (const t of ["pve_vm_create", "pve_vm_configure", "pve_vm_delete"])
25
+ expect(names).not.toContain(t);
24
26
  });
25
27
  it("full registers read AND write tools", () => {
26
28
  const { server, names } = fakeServer();
@@ -32,5 +34,7 @@ describe("registerTools mode gating", () => {
32
34
  expect(names).toContain("pve_snapshot_delete");
33
35
  expect(names).toContain("pve_snapshot_rollback");
34
36
  expect(names).toContain("pve_migrate");
37
+ for (const t of ["pve_vm_create", "pve_vm_configure", "pve_vm_delete"])
38
+ expect(names).toContain(t);
35
39
  });
36
40
  });
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { z } from "zod";
3
+ import { registerTools } from "../register.js";
4
+ import { ProxmoxClient, parseUpid, waitForTask, seg } from "../proxmox-client.js";
5
+ import { toConfig, delta, changesSchema } from "../tools/vm-management.js";
6
+ const SECRET = "11111111-2222-3333-4444-555555555555";
7
+ const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICdITSnEqbi1VNk1AE1DZ4t3RRUwENyJ2Vb4omm0iZ1c bot@factory";
8
+ const upidOf = (node, type, id) => `UPID:${node}:0000A1B2:0001C3D4:6720F00D:${type}:${id}:root@pam!bot:`;
9
+ /** Stateful fake Proxmox: VMs, clone/config/delete, tasks that run for `taskPolls` polls. */
10
+ function world(opts = {}) {
11
+ const vms = new Map([
12
+ [9000, { node: "pve1", config: { name: "tpl-debian12", template: 1, cores: 2, memory: 2048 }, status: "stopped" }],
13
+ [101, { node: "pve1", config: { name: "app-01", cores: 2, memory: 4096, agent: "1", tags: "prod;web" }, status: "running" }],
14
+ [102, { node: "pve2", config: { name: "old-vm", cores: 1, memory: 1024 }, status: "stopped" }],
15
+ ]);
16
+ const tasks = new Map();
17
+ const calls = [];
18
+ const nodes = [{ node: "pve1", status: "online" }, { node: "pve2", status: "online" }, { node: "pve3", status: "offline" }];
19
+ const startTask = (upid, after) => {
20
+ tasks.set(upid, { polls: opts.taskPolls ?? 2, after, exit: opts.failTask ?? "OK" });
21
+ return upid;
22
+ };
23
+ const json = (data, status = 200) => ({ ok: status < 300, status, text: async () => JSON.stringify(status < 300 ? { data } : { errors: data }) });
24
+ const fetcher = async (url, init) => {
25
+ const u = new URL(url);
26
+ const path = u.pathname.replace(/^\/api2\/json/, "");
27
+ const body = init.body ? new URLSearchParams(init.body) : undefined;
28
+ calls.push({ method: init.method, path, body, query: u.searchParams, headers: init.headers });
29
+ const m = (re) => path.match(re);
30
+ let r;
31
+ if (init.method === "GET" && path === "/nodes")
32
+ return json(nodes);
33
+ if (init.method === "GET" && path === "/cluster/resources")
34
+ return json([...vms].map(([id, v]) => ({ vmid: id, node: v.node, type: "qemu", name: v.config.name })));
35
+ if ((r = m(/^\/nodes\/([^/]+)\/tasks\/([^/]+)\/status$/))) {
36
+ const upid = decodeURIComponent(r[2]);
37
+ const t = tasks.get(upid);
38
+ if (!t)
39
+ return json("no such task", 404);
40
+ if (opts.malformed)
41
+ return json({ weird: true });
42
+ if (opts.neverEnds || t.polls-- > 0)
43
+ return json({ status: "running" });
44
+ if (t.exit === "OK")
45
+ t.after();
46
+ return json({ status: "stopped", exitstatus: t.exit });
47
+ }
48
+ if ((r = m(/^\/nodes\/([^/]+)\/qemu\/(\d+)(\/.*)?$/))) {
49
+ const [n, id, rest = ""] = [r[1], Number(r[2]), r[3]];
50
+ const vm = vms.get(id);
51
+ if (!vm || vm.node !== n)
52
+ return json(`Configuration file 'nodes/${n}/qemu-server/${id}.conf' does not exist`, 500);
53
+ if (init.method === "GET" && rest === "/config")
54
+ return json(vm.config);
55
+ if (init.method === "GET" && rest === "/status/current")
56
+ return json({ status: vm.status, ...(vm.ha ? { ha: { managed: 1 } } : {}) });
57
+ if (init.method === "GET" && rest === "/pending")
58
+ return json(Object.entries(vm.config).map(([key, value]) => ({ key, value, ...(key === "memory" && vm.status === "running" ? { pending: value } : {}) })));
59
+ if (init.method === "POST" && rest === "/clone") {
60
+ const newid = Number(body.get("newid"));
61
+ return json(startTask(upidOf(n, "qmclone", id), () => vms.set(newid, { node: body.get("target"), status: "stopped", config: { ...vm.config, name: body.get("name"), template: 0, ...(body.get("description") ? { description: body.get("description") } : {}) } })));
62
+ }
63
+ if (init.method === "PUT" && rest === "/config") {
64
+ const apply = () => {
65
+ for (const [k, v] of body)
66
+ if (k !== opts.dropChange)
67
+ vm.config[k] = v;
68
+ };
69
+ if (opts.putReturnsUpid)
70
+ return json(startTask(upidOf(n, "qmconfig", id), apply));
71
+ apply();
72
+ return json(null);
73
+ }
74
+ if (init.method === "DELETE" && rest === "")
75
+ return json(startTask(upidOf(n, "qmdestroy", id), () => vms.delete(id)));
76
+ }
77
+ return json(`no route ${init.method} ${path}`, 501);
78
+ };
79
+ const pve = new ProxmoxClient({ host: "https://pve.test:8006", tokenId: "root@pam!bot", tokenSecret: SECRET }, fetcher);
80
+ const tools = new Map();
81
+ const server = {
82
+ tool: () => { },
83
+ registerTool: (name, def, handler) => tools.set(name, { schema: def.inputSchema, def, handler }),
84
+ };
85
+ let clock = 0;
86
+ registerTools(server, pve, "full", { wait: { sleep: async (ms) => void (clock += ms), now: () => clock, intervalMs: 1000 } });
87
+ /** Parses like the SDK would (strict schema), then calls. */
88
+ const call = async (name, args) => {
89
+ const t = tools.get(name);
90
+ const parsed = t.schema.safeParse(args);
91
+ if (!parsed.success)
92
+ return { text: `INVALID: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
93
+ const r = await t.handler(parsed.data);
94
+ return { text: r.content[0].text, isError: r.isError === true };
95
+ };
96
+ return { vms, calls, call, tools, pve };
97
+ }
98
+ describe("client", () => {
99
+ it("PUT and DELETE: form body for PUT, query for DELETE, { data } envelope unwrapped", async () => {
100
+ const seen = [];
101
+ const pve = new ProxmoxClient({ host: "https://pve:8006", tokenId: "t@pam!x", tokenSecret: SECRET }, async (url, init) => {
102
+ seen.push({ url, ...init });
103
+ return { ok: true, status: 200, text: async () => JSON.stringify({ data: "UPID:x" }) };
104
+ });
105
+ expect(await pve.put("/nodes/n/qemu/1/config", { cores: 2, name: "a b", skip: undefined })).toBe("UPID:x");
106
+ expect(seen[0]).toMatchObject({ method: "PUT", body: "cores=2&name=a+b" });
107
+ expect(seen[0].headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
108
+ await pve.del("/nodes/n/qemu/1", { purge: 1 });
109
+ expect(seen[1].url).toBe("https://pve:8006/api2/json/nodes/n/qemu/1?purge=1");
110
+ expect(seen[1].body).toBeUndefined();
111
+ });
112
+ it("errors never carry the token, even when the server echoes it", async () => {
113
+ const pve = new ProxmoxClient({ host: "https://pve:8006", tokenId: "t@pam!x", tokenSecret: SECRET }, async (_u, init) => ({ ok: false, status: 401, text: async () => `bad header ${init.headers.Authorization}` }));
114
+ const err = (await pve.get("/nodes").catch((e) => e));
115
+ expect(err.message).toMatch(/401/);
116
+ expect(err.message).not.toContain(SECRET);
117
+ });
118
+ it("UPID: parsed, node extracted, encoded in the status path; malformed ones refused", () => {
119
+ const u = upidOf("pve2", "qmclone", 9000);
120
+ expect(parseUpid(u)).toEqual({ upid: u, node: "pve2" });
121
+ expect(seg(u)).not.toContain(":");
122
+ for (const bad of ["UPID:../x:1:2:3:t:i:u:", "nope", 42, null, "UPID:pve1:0000A1B2:0001C3D4:6720F00D:qmclone:9000:root@pam!bot", `${u}/../..`])
123
+ expect(() => parseUpid(bad)).toThrow(/Malformed/);
124
+ });
125
+ });
126
+ describe("waitForTask", () => {
127
+ it("running → stopped/OK; error exit; timeout; malformed status", async () => {
128
+ const t = world({ taskPolls: 2 });
129
+ const r = await t.call("pve_vm_create", { sourceNode: "pve1", templateVmid: 9000, targetNode: "pve2", vmid: 200, name: "bot-01" });
130
+ expect(r.isError).toBe(false);
131
+ const statusCalls = t.calls.filter((c) => c.path.includes("/tasks/"));
132
+ expect(statusCalls.length).toBe(3);
133
+ expect(statusCalls[0].path).toBe(`/nodes/pve1/tasks/${encodeURIComponent(upidOf("pve1", "qmclone", 9000))}/status`);
134
+ const failed = await world({ failTask: "clone failed: storage full" }).call("pve_vm_create", { sourceNode: "pve1", templateVmid: 9000, targetNode: "pve2", vmid: 200, name: "bot-01" });
135
+ expect(failed.text).toMatch(/Task failed: clone failed: storage full — task UPID:pve1:.*last status: stopped \/ clone failed/);
136
+ const slow = await world({ neverEnds: true }).call("pve_vm_delete", { node: "pve2", vmid: 102, expectedName: "old-vm" });
137
+ expect(slow.text).toMatch(/still running after 600s — it may still complete; check pve_tasks/);
138
+ const odd = await world({ malformed: true }).call("pve_vm_delete", { node: "pve2", vmid: 102, expectedName: "old-vm" });
139
+ expect(odd.text).toMatch(/Malformed task status/);
140
+ await expect(waitForTask(t.pve, "garbage", { timeoutMs: 1 })).rejects.toThrow(/Malformed task id/);
141
+ });
142
+ });
143
+ describe("pve_vm_create", () => {
144
+ const base = { sourceNode: "pve1", templateVmid: 9000, targetNode: "pve2", vmid: 200, name: "bot-01" };
145
+ it("clones a template, waits for the task and verifies the new VM", async () => {
146
+ const w = world();
147
+ const r = await w.call("pve_vm_create", { ...base, storage: "local-lvm", pool: "bots", description: "Bot Factory" });
148
+ expect(r.isError).toBe(false);
149
+ expect(r.text).toMatch(/VM 200 "bot-01" created on pve2/);
150
+ expect(r.text).toMatch(/full clone to local-lvm · pool bots/);
151
+ const clone = w.calls.find((c) => c.method === "POST");
152
+ expect(clone.path).toBe("/nodes/pve1/qemu/9000/clone");
153
+ expect(Object.fromEntries(clone.body)).toEqual({ newid: "200", name: "bot-01", target: "pve2", full: "1", storage: "local-lvm", pool: "bots", description: "Bot Factory" });
154
+ expect(w.vms.get(200)?.node).toBe("pve2");
155
+ });
156
+ it("refuses a non-template source, a used VMID, an unknown or offline node — without any write", async () => {
157
+ for (const [args, msg] of [
158
+ [{ ...base, templateVmid: 101 }, /not a template/],
159
+ [{ ...base, vmid: 102 }, /VMID 102 is already used .*old-vm.*refusing to overwrite/],
160
+ [{ ...base, targetNode: "pve9" }, /not in the cluster/],
161
+ [{ ...base, targetNode: "pve3" }, /is offline/],
162
+ [{ ...base, templateVmid: 9999 }, /Template 9999 not found/],
163
+ [{ ...base, fullClone: false, storage: "local-lvm" }, /storage only applies to a full clone/],
164
+ ]) {
165
+ const w = world();
166
+ const r = await w.call("pve_vm_create", args);
167
+ expect(r.isError).toBe(true);
168
+ expect(r.text).toMatch(msg);
169
+ expect(w.calls.filter((c) => c.method !== "GET")).toEqual([]);
170
+ }
171
+ });
172
+ it("strict schema: unknown properties, bad VMIDs and names are rejected", async () => {
173
+ const w = world();
174
+ for (const args of [
175
+ { ...base, args: "-cpu host" },
176
+ { ...base, vmid: 99 },
177
+ { ...base, vmid: 1.5 },
178
+ { ...base, name: "bot_01;rm" },
179
+ { ...base, name: "-bot" },
180
+ { ...base, sourceNode: "../etc" },
181
+ { ...base, storage: "local lvm" },
182
+ ]) {
183
+ const r = await w.call("pve_vm_create", args);
184
+ expect(r.text).toMatch(/^INVALID/);
185
+ }
186
+ expect(w.calls).toEqual([]);
187
+ });
188
+ });
189
+ describe("pve_vm_configure", () => {
190
+ it("sends only the delta, verifies it, reports pending changes on a running VM; SSH keys never fully echoed", async () => {
191
+ const w = world();
192
+ const r = await w.call("pve_vm_configure", {
193
+ node: "pve1",
194
+ vmid: 101,
195
+ expectedName: "app-01",
196
+ changes: { cores: 2, memoryMiB: 8192, agent: true, tags: ["web", "prod"], ipConfig0: { ip: "10.0.0.20/24", gw: "10.0.0.1" }, nameservers: ["1.1.1.1"], sshPublicKeys: [KEY] },
197
+ });
198
+ expect(r.isError).toBe(false);
199
+ const put = w.calls.find((c) => c.method === "PUT");
200
+ expect([...put.body.keys()].sort()).toEqual(["ipconfig0", "memory", "nameserver", "sshkeys"]);
201
+ expect(put.body.get("ipconfig0")).toBe("ip=10.0.0.20/24,gw=10.0.0.1");
202
+ expect(decodeURIComponent(put.body.get("sshkeys"))).toBe(KEY + "\n");
203
+ expect(r.text).toMatch(/unchanged \(not sent\): cores, agent, tags/);
204
+ expect(r.text).toMatch(/memory apply at the next reboot \(pending\)/);
205
+ expect(r.text).toMatch(/sshkeys: 1 key\(s\): ssh-ed25519 …/);
206
+ expect(r.text).not.toContain("AAAAC3NzaC1lZDI1NTE5AAAAICdITSnEqbi1VNk1AE1DZ4t3RRUwENyJ2Vb4omm0iZ1c");
207
+ });
208
+ it("already conforming → no-op, nothing sent", async () => {
209
+ const w = world();
210
+ const r = await w.call("pve_vm_configure", { node: "pve1", vmid: 101, expectedName: "app-01", changes: { cores: 2, agent: true, tags: ["prod", "web"], onboot: false } });
211
+ expect(r.text).toMatch(/^No-op/);
212
+ expect(w.calls.some((c) => c.method === "PUT")).toBe(false);
213
+ });
214
+ it("wrong expectedName, template, empty or non-allowlisted changes are refused", async () => {
215
+ const w = world();
216
+ expect((await w.call("pve_vm_configure", { node: "pve1", vmid: 101, expectedName: "app-02", changes: { cores: 4 } })).text).toMatch(/Identity check failed/);
217
+ expect((await w.call("pve_vm_configure", { node: "pve1", vmid: 9000, expectedName: "tpl-debian12", changes: { cores: 4 } })).text).toMatch(/is a template/);
218
+ for (const changes of [{}, { args: "-device vfio" }, { net0: "virtio,bridge=vmbr0" }, { scsi0: "local:10" }, { cores: 0 }, { memoryMiB: 100 }, { cpuType: "evil" }, { sshPublicKeys: ["not a key"] }, { ipConfig0: { ip: "dhcp", gw: "10.0.0.1" } }, { description: "a\u0000b" }, { tags: ["Bad Tag"] }]) {
219
+ expect((await w.call("pve_vm_configure", { node: "pve1", vmid: 101, expectedName: "app-01", changes })).text).toMatch(/^INVALID/);
220
+ }
221
+ expect(w.calls.some((c) => c.method === "PUT")).toBe(false);
222
+ });
223
+ it("a change the server did not apply fails verification; a UPID answer is awaited", async () => {
224
+ const dropped = await world({ dropChange: "memory" }).call("pve_vm_configure", { node: "pve2", vmid: 102, expectedName: "old-vm", changes: { memoryMiB: 2048 } });
225
+ expect(dropped.text).toMatch(/not verified for: memory/);
226
+ const w = world({ putReturnsUpid: true });
227
+ const ok = await w.call("pve_vm_configure", { node: "pve2", vmid: 102, expectedName: "old-vm", changes: { name: "new-vm", onboot: true } });
228
+ expect(ok.isError).toBe(false);
229
+ expect(w.calls.filter((c) => c.path.includes("/tasks/")).length).toBeGreaterThan(0);
230
+ expect(w.vms.get(102).config.name).toBe("new-vm");
231
+ });
232
+ });
233
+ describe("pve_vm_delete", () => {
234
+ it("deletes a stopped VM, waits, verifies it is gone; options are explicit", async () => {
235
+ const w = world();
236
+ const r = await w.call("pve_vm_delete", { node: "pve2", vmid: 102, expectedName: "old-vm" });
237
+ expect(r.isError).toBe(false);
238
+ expect(r.text).toMatch(/Verified: the VMID no longer exists/);
239
+ const del = w.calls.find((c) => c.method === "DELETE");
240
+ expect(Object.fromEntries(del.query)).toEqual({ purge: "0", "destroy-unreferenced-disks": "0" });
241
+ expect(w.vms.has(102)).toBe(false);
242
+ });
243
+ it("refuses a running VM (never stops it), a wrong name, a template, HA without purge", async () => {
244
+ const w = world();
245
+ expect((await w.call("pve_vm_delete", { node: "pve1", vmid: 101, expectedName: "app-01" })).text).toMatch(/is running: stop it first .* never stopped automatically/);
246
+ expect((await w.call("pve_vm_delete", { node: "pve2", vmid: 102, expectedName: "old" })).text).toMatch(/Identity check failed/);
247
+ expect((await w.call("pve_vm_delete", { node: "pve1", vmid: 9000, expectedName: "tpl-debian12" })).text).toMatch(/is a template/);
248
+ expect((await w.call("pve_vm_delete", { node: "pve1", vmid: 555, expectedName: "x" })).text).toMatch(/not found/);
249
+ w.vms.get(102).ha = true;
250
+ expect((await w.call("pve_vm_delete", { node: "pve2", vmid: 102, expectedName: "old-vm" })).text).toMatch(/managed by HA: pass purge=true/);
251
+ expect(w.calls.some((c) => c.method !== "GET")).toBe(false);
252
+ });
253
+ it("is annotated destructive; all three are [WRITE] and strict", () => {
254
+ const w = world();
255
+ expect(w.tools.get("pve_vm_delete").def.annotations).toMatchObject({ destructiveHint: true, readOnlyHint: false });
256
+ expect(w.tools.get("pve_vm_create").def.annotations.destructiveHint).toBe(false);
257
+ for (const n of ["pve_vm_create", "pve_vm_configure", "pve_vm_delete"]) {
258
+ expect(w.tools.get(n).def.description).toMatch(/^\[WRITE\]/);
259
+ expect(z.toJSONSchema(w.tools.get(n).schema).additionalProperties).toBe(false);
260
+ }
261
+ });
262
+ });
263
+ describe("config mapping", () => {
264
+ it("normalises current values so equal configs compare equal", () => {
265
+ expect(delta({ agent: "enabled=1,fstrim_cloned_disks=1", cpu: "cputype=x86-64-v2-AES,flags=+aes", tags: "web;prod" }, toConfig(changesSchema.parse({ agent: true, cpuType: "x86-64-v2-AES", tags: ["prod", "web"] })))).toEqual({});
266
+ expect(delta({}, toConfig(changesSchema.parse({ cores: 1, onboot: false })))).toEqual({});
267
+ });
268
+ });
@@ -11,12 +11,32 @@ export function validatePath(path) {
11
11
  export function buildAuthHeader(tokenId, tokenSecret) {
12
12
  return `PVEAPIToken=${tokenId}=${tokenSecret}`;
13
13
  }
14
+ /** One URL path segment (node, storage, UPID…): always percent-encoded. */
15
+ export const seg = (v) => encodeURIComponent(String(v));
16
+ export class ProxmoxApiError extends Error {
17
+ status;
18
+ constructor(status, message) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
14
23
  export class ProxmoxClient {
15
24
  baseUrl;
16
25
  authHeader;
17
- constructor(config) {
26
+ secrets;
27
+ fetcher;
28
+ constructor(config, fetcher) {
18
29
  this.baseUrl = `${config.host.replace(/\/+$/, "")}/api2/json`;
19
30
  this.authHeader = buildAuthHeader(config.tokenId, config.tokenSecret);
31
+ this.secrets = [this.authHeader, config.tokenSecret].filter((x) => x.length >= 4);
32
+ this.fetcher = fetcher ?? ((url, init) => fetch(url, init));
33
+ }
34
+ /** Error text never carries the token, even if a proxy echoes the request. */
35
+ scrub(text) {
36
+ let out = text;
37
+ for (const s of this.secrets)
38
+ out = out.split(s).join("[REDACTED]");
39
+ return out;
20
40
  }
21
41
  async request(method, path, opts = {}) {
22
42
  validatePath(path);
@@ -45,15 +65,16 @@ export class ProxmoxClient {
45
65
  body = form.toString();
46
66
  headers["Content-Type"] = "application/x-www-form-urlencoded";
47
67
  }
48
- const res = await fetch(url, {
49
- method: method.toUpperCase(),
50
- headers,
51
- body,
52
- signal: AbortSignal.timeout(TIMEOUT_MS),
53
- });
68
+ let res;
69
+ try {
70
+ res = await this.fetcher(url, { method: method.toUpperCase(), headers, body, signal: AbortSignal.timeout(TIMEOUT_MS) });
71
+ }
72
+ catch (err) {
73
+ throw new Error(this.scrub(`Proxmox API ${method.toUpperCase()} ${path} → ${err instanceof Error ? err.message : String(err)}`));
74
+ }
54
75
  const text = await res.text();
55
76
  if (!res.ok) {
56
- throw new Error(`Proxmox API ${method.toUpperCase()} ${path} → ${res.status}: ${text.slice(0, 500)}`);
77
+ throw new ProxmoxApiError(res.status, this.scrub(`Proxmox API ${method.toUpperCase()} ${path} → ${res.status}: ${text.slice(0, 500)}`));
57
78
  }
58
79
  if (!text)
59
80
  return undefined;
@@ -66,7 +87,54 @@ export class ProxmoxClient {
66
87
  post(path, body) {
67
88
  return this.request("POST", path, { body });
68
89
  }
69
- del(path) {
70
- return this.request("DELETE", path);
90
+ put(path, body) {
91
+ return this.request("PUT", path, { body });
92
+ }
93
+ /** DELETE takes its options in the query string (Proxmox ignores a DELETE body). */
94
+ del(path, params) {
95
+ return this.request("DELETE", path, { query: params });
96
+ }
97
+ }
98
+ // ---------------------------------------------------------------- tasks (UPID)
99
+ /** UPID:node:pid:pstart:starttime:type:id:user: — every field validated before it is used. */
100
+ const UPID_RE = /^UPID:([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,62})):[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8,16}:[0-9A-Fa-f]{8}:[a-zA-Z0-9_-]{1,64}:[^:\s]{0,128}:[^:\s]{1,256}:$/;
101
+ export function parseUpid(upid) {
102
+ if (typeof upid !== "string" || !UPID_RE.test(upid))
103
+ throw new Error(`Malformed task id from Proxmox: ${JSON.stringify(upid)?.slice(0, 120)}`);
104
+ return { upid, node: upid.match(UPID_RE)[1] };
105
+ }
106
+ export class TaskError extends Error {
107
+ upid;
108
+ last;
109
+ constructor(upid, last, message) {
110
+ super(`${message} — task ${upid}, last status: ${last ? `${last.status}${last.exitstatus ? ` / ${last.exitstatus}` : ""}` : "none"}`);
111
+ this.upid = upid;
112
+ this.last = last;
113
+ }
114
+ }
115
+ /** Polls a task until it stops; only exitstatus "OK" is a success. Bounded by `timeoutMs`. */
116
+ export async function waitForTask(pve, upidRaw, opts) {
117
+ const { upid, node } = parseUpid(upidRaw);
118
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
119
+ const now = opts.now ?? (() => Date.now());
120
+ const deadline = now() + opts.timeoutMs;
121
+ let interval = opts.intervalMs ?? 1000;
122
+ let last;
123
+ for (;;) {
124
+ const st = await pve.get(`/nodes/${seg(node)}/tasks/${seg(upid)}/status`);
125
+ if (!st || typeof st !== "object" || typeof st.status !== "string")
126
+ throw new TaskError(upid, last, "Malformed task status from Proxmox");
127
+ last = st;
128
+ if (st.status === "stopped") {
129
+ if (st.exitstatus === "OK")
130
+ return st;
131
+ throw new TaskError(upid, st, `Task failed: ${st.exitstatus ?? "no exit status"}`);
132
+ }
133
+ if (st.status !== "running")
134
+ throw new TaskError(upid, st, `Unexpected task status "${st.status}"`);
135
+ if (now() + interval > deadline)
136
+ throw new TaskError(upid, st, `Task still running after ${Math.round(opts.timeoutMs / 1000)}s — it may still complete; check pve_tasks before retrying`);
137
+ await sleep(interval);
138
+ interval = Math.min(interval * 1.5, 5000);
71
139
  }
72
140
  }
package/dist/register.js CHANGED
@@ -6,11 +6,12 @@ import { registerRawTools } from "./tools/raw.js";
6
6
  import { registerLifecycleTools } from "./tools/lifecycle.js";
7
7
  import { registerSnapshotTools } from "./tools/snapshots.js";
8
8
  import { registerMigrateTools } from "./tools/migrate.js";
9
+ import { registerVmManagementTools } from "./tools/vm-management.js";
9
10
  export function parseMode(raw) {
10
11
  return raw === "full" ? "full" : "readonly";
11
12
  }
12
13
  /** Read tools always register; write tools only in `full` mode. */
13
- export function registerTools(server, client, mode) {
14
+ export function registerTools(server, client, mode, opts = {}) {
14
15
  registerClusterTools(server, client);
15
16
  registerVmTools(server, client);
16
17
  registerStorageTools(server, client);
@@ -20,5 +21,7 @@ export function registerTools(server, client, mode) {
20
21
  registerLifecycleTools(server, client);
21
22
  registerSnapshotTools(server, client);
22
23
  registerMigrateTools(server, client);
24
+ // typed VM provisioning (create from template, allowlisted config, guarded delete)
25
+ registerVmManagementTools(server, client, opts);
23
26
  }
24
27
  }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Typed QEMU VM provisioning (issue #75), `full` mode only: clone from a template, apply an
3
+ * allowlisted configuration delta, delete a stopped VM. No raw method, path or Proxmox parameter
4
+ * reaches the API: every field is typed, bounded and mapped here.
5
+ *
6
+ * Every mutation waits for its task (UPID) to reach a terminal state — only exitstatus "OK" is a
7
+ * success — and is then verified by reading the resource back. Approval stays in Pulse Chat.
8
+ */
9
+ import { z } from "zod";
10
+ import { ProxmoxApiError, seg, waitForTask } from "../proxmox-client.js";
11
+ import { textResult, errResult } from "./utils.js";
12
+ export const TIMEOUTS = { create: 30 * 60_000, configure: 2 * 60_000, delete: 10 * 60_000 };
13
+ // ---------------------------------------------------------------- schemas
14
+ const node = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$/, "node name: letters, digits, -");
15
+ const vmid = z.number().int().min(100).max(999_999_999);
16
+ const vmName = z
17
+ .string()
18
+ .max(63)
19
+ .regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/, "VM name: a DNS name (letters, digits, - and .)");
20
+ const storageId = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/, "storage id");
21
+ const poolId = z.string().regex(/^[a-zA-Z0-9_.-]{1,64}$/, "pool id");
22
+ const freeText = (max) => z.string().max(max).refine((s) => !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(s), "control characters are not allowed");
23
+ const ipv4 = z.ipv4();
24
+ const cidr4 = z.cidrv4();
25
+ const cidr6 = z.cidrv6();
26
+ const domain = z.string().max(253).regex(/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, "domain name");
27
+ const SSH_KEY_RE = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]{40,}={0,3}(?: [\x21-\x7e][\x20-\x7e]{0,199})?$/;
28
+ const sshKey = z.string().max(16_384).regex(SSH_KEY_RE, "an OpenSSH public key (type base64 [comment]), one per entry");
29
+ export const CPU_TYPES = ["host", "max", "kvm64", "qemu64", "x86-64-v2", "x86-64-v2-AES", "x86-64-v3", "x86-64-v4"];
30
+ const ipConfig = z
31
+ .strictObject({
32
+ ip: z.union([z.literal("dhcp"), cidr4]).optional().describe('IPv4: "dhcp" or CIDR, e.g. 10.0.0.20/24'),
33
+ gw: ipv4.optional().describe("IPv4 gateway (static IPv4 only)"),
34
+ ip6: z.union([z.literal("dhcp"), z.literal("auto"), cidr6]).optional().describe('IPv6: "dhcp", "auto" (SLAAC) or CIDR'),
35
+ gw6: z.ipv6().optional().describe("IPv6 gateway (static IPv6 only)"),
36
+ })
37
+ .refine((c) => c.ip || c.ip6, "ipConfig0 needs ip and/or ip6")
38
+ .refine((c) => !c.gw || (c.ip && c.ip !== "dhcp"), "gw needs a static ip")
39
+ .refine((c) => !c.gw6 || (c.ip6 && c.ip6 !== "dhcp" && c.ip6 !== "auto"), "gw6 needs a static ip6");
40
+ export const changesSchema = z
41
+ .strictObject({
42
+ name: vmName.optional(),
43
+ cores: z.number().int().min(1).max(128).optional(),
44
+ sockets: z.number().int().min(1).max(8).optional(),
45
+ memoryMiB: z.number().int().min(256).max(1_048_576).optional().describe("RAM in MiB (256 MiB – 1 TiB)"),
46
+ cpuType: z.enum(CPU_TYPES).optional(),
47
+ onboot: z.boolean().optional(),
48
+ agent: z.boolean().optional().describe("QEMU guest agent enabled"),
49
+ description: freeText(8192).optional(),
50
+ tags: z.array(z.string().regex(/^[a-z0-9_][a-z0-9_.+-]{0,63}$/, "tag: lowercase letters, digits, _ . + -")).max(20).optional(),
51
+ ipConfig0: ipConfig.optional().describe("cloud-init network of the first NIC"),
52
+ nameservers: z.array(z.union([ipv4, z.ipv6()])).min(1).max(3).optional().describe("cloud-init DNS servers"),
53
+ searchDomains: z.array(domain).min(1).max(6).optional().describe("cloud-init DNS search domains"),
54
+ sshPublicKeys: z.array(sshKey).min(1).max(10).optional().describe("cloud-init authorized SSH public keys (replaces the list)"),
55
+ })
56
+ .refine((c) => Object.keys(c).length > 0, "changes must not be empty");
57
+ /** Proxmox config keys and values for a set of changes. Pure. */
58
+ export function toConfig(c) {
59
+ const out = {};
60
+ if (c.name !== undefined)
61
+ out.name = c.name;
62
+ if (c.cores !== undefined)
63
+ out.cores = String(c.cores);
64
+ if (c.sockets !== undefined)
65
+ out.sockets = String(c.sockets);
66
+ if (c.memoryMiB !== undefined)
67
+ out.memory = String(c.memoryMiB);
68
+ if (c.cpuType !== undefined)
69
+ out.cpu = c.cpuType;
70
+ if (c.onboot !== undefined)
71
+ out.onboot = c.onboot ? "1" : "0";
72
+ if (c.agent !== undefined)
73
+ out.agent = c.agent ? "1" : "0";
74
+ if (c.description !== undefined)
75
+ out.description = c.description;
76
+ if (c.tags !== undefined)
77
+ out.tags = [...new Set(c.tags)].sort().join(";");
78
+ if (c.ipConfig0) {
79
+ const i = c.ipConfig0;
80
+ out.ipconfig0 = [i.ip && `ip=${i.ip}`, i.gw && `gw=${i.gw}`, i.ip6 && `ip6=${i.ip6}`, i.gw6 && `gw6=${i.gw6}`].filter(Boolean).join(",");
81
+ }
82
+ if (c.nameservers)
83
+ out.nameserver = c.nameservers.join(" ");
84
+ if (c.searchDomains)
85
+ out.searchdomain = c.searchDomains.join(" ");
86
+ // Proxmox stores sshkeys URL-encoded (and the form encoding adds a second layer on the wire)
87
+ if (c.sshPublicKeys)
88
+ out.sshkeys = encodeURIComponent(c.sshPublicKeys.join("\n") + "\n");
89
+ return out;
90
+ }
91
+ /** Current value of a key, normalised the way toConfig writes it, to compare. */
92
+ export function currentValue(cfg, key) {
93
+ const v = cfg[key];
94
+ if (v === undefined || v === null)
95
+ return undefined;
96
+ const s = String(v);
97
+ switch (key) {
98
+ case "agent":
99
+ // "1", "enabled=1,fstrim_cloned_disks=1", "0"…
100
+ return /^(1|enabled=1)(,|$)/.test(s) ? "1" : "0";
101
+ case "cpu":
102
+ return s.split(",")[0].replace(/^cputype=/, "");
103
+ case "tags":
104
+ return [...new Set(s.split(/[;, ]+/).filter(Boolean))].sort().join(";");
105
+ case "sshkeys":
106
+ return encodeURIComponent(decodeURIComponent(s)
107
+ .split("\n")
108
+ .map((l) => l.trim())
109
+ .filter(Boolean)
110
+ .join("\n") + "\n");
111
+ case "description":
112
+ return s.replace(/\n$/, "");
113
+ default:
114
+ return s;
115
+ }
116
+ }
117
+ /** Keys whose desired value differs from the current one. */
118
+ export function delta(cfg, wanted) {
119
+ const out = {};
120
+ for (const [k, v] of Object.entries(wanted)) {
121
+ const cur = currentValue(cfg, k);
122
+ // defaults Proxmox does not write back: cores/sockets 1, onboot 0, agent 0
123
+ const dflt = { cores: "1", sockets: "1", onboot: "0", agent: "0" }[k];
124
+ if ((cur ?? dflt) !== (k === "description" ? v.replace(/\n$/, "") : v))
125
+ out[k] = v;
126
+ }
127
+ return out;
128
+ }
129
+ /** What a change looks like in the output: never a full key or a full description. */
130
+ function show(key, value) {
131
+ if (key === "sshkeys") {
132
+ const keys = decodeURIComponent(value).split("\n").filter(Boolean);
133
+ return `${keys.length} key(s): ${keys.map((k) => `${k.split(" ")[0]} …${k.split(" ")[1]?.slice(-8)}`).join(", ")}`;
134
+ }
135
+ if (key === "description")
136
+ return `${value.length} chars`;
137
+ return value;
138
+ }
139
+ // ---------------------------------------------------------------- helpers
140
+ const qemu = (n, id) => `/nodes/${seg(n)}/qemu/${seg(id)}`;
141
+ const isMissing = (err) => err instanceof ProxmoxApiError && (err.status === 404 || (err.status === 500 && /does not exist|not exist/i.test(err.message)));
142
+ async function readConfig(pve, n, id) {
143
+ try {
144
+ return await pve.get(`${qemu(n, id)}/config`);
145
+ }
146
+ catch (err) {
147
+ if (isMissing(err))
148
+ return undefined;
149
+ throw err;
150
+ }
151
+ }
152
+ /** Every VM/CT of the cluster with this VMID (should be zero or one). */
153
+ async function clusterVmids(pve, id) {
154
+ const all = (await pve.get("/cluster/resources", { type: "vm" })) ?? [];
155
+ return all.filter((r) => Number(r.vmid) === id);
156
+ }
157
+ function identity(cfg, n, id, expectedName) {
158
+ if (!cfg)
159
+ throw new Error(`VM ${id} not found on node ${n}`);
160
+ if (cfg.name !== expectedName)
161
+ throw new Error(`Identity check failed: VM ${id} on ${n} is named "${cfg.name ?? "(none)"}", not "${expectedName}" — nothing done`);
162
+ if (String(cfg.template) === "1")
163
+ throw new Error(`VM ${id} is a template: templates are not managed by these tools — nothing done`);
164
+ return cfg;
165
+ }
166
+ const annotate = (title, destructive, idempotent) => ({ title, readOnlyHint: false, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: true });
167
+ // ---------------------------------------------------------------- tools
168
+ export function registerVmManagementTools(server, pve, opts = {}) {
169
+ const wait = (upid, timeoutMs) => waitForTask(pve, upid, { timeoutMs, ...opts.wait });
170
+ server.registerTool("pve_vm_create", {
171
+ description: "[WRITE] Create a QEMU VM by cloning an existing template (full clone by default). Checks that the source is a template, that the target node exists and that the VMID is free cluster-wide — it never overwrites. Waits for the clone task and verifies the new VM.",
172
+ inputSchema: z.strictObject({
173
+ sourceNode: node.describe("Node holding the template"),
174
+ templateVmid: vmid.describe("VMID of the template to clone"),
175
+ targetNode: node.describe("Node of the new VM"),
176
+ vmid: vmid.describe("VMID of the new VM (must be free in the cluster)"),
177
+ name: vmName.describe("Name of the new VM (DNS name)"),
178
+ fullClone: z.boolean().default(true).describe("Full clone (default) or linked clone"),
179
+ storage: storageId.optional().describe("Target storage for the disks (full clone only)"),
180
+ pool: poolId.optional().describe("Resource pool to add the VM to"),
181
+ description: freeText(8192).optional(),
182
+ }),
183
+ annotations: annotate("Create VM from template", false, false),
184
+ }, async (a) => {
185
+ try {
186
+ if (a.storage && !a.fullClone)
187
+ throw new Error("storage only applies to a full clone");
188
+ const [tpl, nodes, taken] = await Promise.all([readConfig(pve, a.sourceNode, a.templateVmid), pve.get("/nodes"), clusterVmids(pve, a.vmid)]);
189
+ if (!tpl)
190
+ throw new Error(`Template ${a.templateVmid} not found on node ${a.sourceNode}`);
191
+ if (String(tpl.template) !== "1")
192
+ throw new Error(`VM ${a.templateVmid} on ${a.sourceNode} is not a template — refusing to clone a regular VM`);
193
+ const target = (nodes ?? []).find((x) => x.node === a.targetNode);
194
+ if (!target)
195
+ throw new Error(`Target node "${a.targetNode}" is not in the cluster (${(nodes ?? []).map((x) => x.node).join(", ")})`);
196
+ if (target.status && target.status !== "online")
197
+ throw new Error(`Target node "${a.targetNode}" is ${target.status}`);
198
+ if (taken.length)
199
+ throw new Error(`VMID ${a.vmid} is already used (${taken.map((t) => `${t.type ?? "vm"} "${t.name ?? "?"}" on ${t.node ?? "?"}`).join(", ")}) — refusing to overwrite`);
200
+ const upid = await pve.post(`${qemu(a.sourceNode, a.templateVmid)}/clone`, {
201
+ newid: a.vmid,
202
+ name: a.name,
203
+ target: a.targetNode,
204
+ full: a.fullClone ? 1 : 0,
205
+ storage: a.storage,
206
+ pool: a.pool,
207
+ description: a.description,
208
+ });
209
+ const task = await wait(upid, TIMEOUTS.create);
210
+ const cfg = await readConfig(pve, a.targetNode, a.vmid);
211
+ if (!cfg)
212
+ throw new Error(`Clone task ${upid} ended OK but VM ${a.vmid} is not on ${a.targetNode} — check pve_tasks`);
213
+ if (cfg.name !== a.name)
214
+ throw new Error(`Clone task ended OK but VM ${a.vmid} is named "${cfg.name}", not "${a.name}" — check it before use`);
215
+ if (String(cfg.template) === "1")
216
+ throw new Error(`VM ${a.vmid} came out as a template — check it before use`);
217
+ return textResult([
218
+ `# VM ${a.vmid} "${a.name}" created on ${a.targetNode}`,
219
+ "",
220
+ `- Cloned from template ${a.templateVmid} (${tpl.name ?? "?"}) on ${a.sourceNode} — ${a.fullClone ? "full" : "linked"} clone${a.storage ? ` to ${a.storage}` : ""}${a.pool ? ` · pool ${a.pool}` : ""}`,
221
+ `- Task ${upid}: ${task.exitstatus}`,
222
+ `- Verified: config read back (${cfg.cores ?? 1} core(s) × ${cfg.sockets ?? 1}, ${cfg.memory ?? "?"} MiB). The VM is stopped; configure it with pve_vm_configure, start it with pve_vm_action.`,
223
+ ].join("\n"));
224
+ }
225
+ catch (err) {
226
+ return errResult(err);
227
+ }
228
+ });
229
+ server.registerTool("pve_vm_configure", {
230
+ description: "[WRITE] Apply an allowlisted configuration delta to a QEMU VM: name, cores, sockets, memory, CPU type, onboot, guest agent, description, tags and structured cloud-init (ipConfig0, nameservers, search domains, SSH public keys). Requires the exact current name. Unchanged values are not sent; nothing to change is a no-op. Disks, network devices, args and passthrough are not configurable.",
231
+ inputSchema: z.strictObject({
232
+ node: node.describe("Node of the VM"),
233
+ vmid: vmid,
234
+ expectedName: vmName.describe("Exact current name of the VM (identity check)"),
235
+ changes: changesSchema,
236
+ }),
237
+ annotations: annotate("Configure VM", false, true),
238
+ }, async (a) => {
239
+ try {
240
+ const cfg = identity(await readConfig(pve, a.node, a.vmid), a.node, a.vmid, a.expectedName);
241
+ if (cfg.lock)
242
+ throw new Error(`VM ${a.vmid} is locked (${cfg.lock}) — retry when the running operation ends`);
243
+ const wanted = toConfig(a.changes);
244
+ const d = delta(cfg, wanted);
245
+ if (!Object.keys(d).length)
246
+ return textResult(`No-op: VM ${a.vmid} "${a.expectedName}" already has this configuration (${Object.keys(wanted).join(", ")}). Nothing sent.`);
247
+ // PUT /config is synchronous (no task); some setups answer with a UPID anyway: then wait for it
248
+ const res = await pve.put(`${qemu(a.node, a.vmid)}/config`, d);
249
+ if (typeof res === "string" && res.startsWith("UPID:"))
250
+ await wait(res, TIMEOUTS.configure);
251
+ const after = await readConfig(pve, a.node, a.vmid);
252
+ if (!after)
253
+ throw new Error(`VM ${a.vmid} disappeared after the configuration — check it`);
254
+ const still = delta(after, d);
255
+ if (Object.keys(still).length)
256
+ throw new Error(`Configuration written but not verified for: ${Object.keys(still).join(", ")} — read the VM config before retrying`);
257
+ const status = await pve.get(`${qemu(a.node, a.vmid)}/status/current`).catch(() => undefined);
258
+ const pending = status?.status === "running" ? await pve.get(`${qemu(a.node, a.vmid)}/pending`).catch(() => []) : [];
259
+ const waiting = (pending ?? []).filter((p) => p.key && p.pending !== undefined && p.key in d).map((p) => p.key);
260
+ return textResult([
261
+ `# VM ${a.vmid} "${d.name ?? a.expectedName}" configured on ${a.node}`,
262
+ "",
263
+ ...Object.entries(d).map(([k, v]) => `- ${k}: ${show(k, v)}`),
264
+ ...(Object.keys(wanted).length > Object.keys(d).length ? [`- unchanged (not sent): ${Object.keys(wanted).filter((k) => !(k in d)).join(", ")}`] : []),
265
+ "",
266
+ `Verified: config read back.${waiting.length ? ` The VM is running: ${waiting.join(", ")} apply at the next reboot (pending).` : ""}${"sshkeys" in d || "ipconfig0" in d || "nameserver" in d || "searchdomain" in d ? " cloud-init changes apply at the next boot." : ""}`,
267
+ ].join("\n"));
268
+ }
269
+ catch (err) {
270
+ return errResult(err);
271
+ }
272
+ });
273
+ server.registerTool("pve_vm_delete", {
274
+ description: "[WRITE][DESTRUCTIVE] Delete a stopped QEMU VM and its disks. Requires the exact current name; refuses a running, locked, protected or template VM and never stops it. purge also removes it from backup jobs, replication and HA; destroyUnreferencedDisks also deletes disks not referenced in its config. Waits for the task and verifies the VM is gone.",
275
+ inputSchema: z.strictObject({
276
+ node: node.describe("Node of the VM"),
277
+ vmid: vmid,
278
+ expectedName: vmName.describe("Exact current name of the VM (identity check)"),
279
+ purge: z.boolean().default(false).describe("Also remove the VMID from backup jobs, replication and HA"),
280
+ destroyUnreferencedDisks: z.boolean().default(false).describe("Also destroy disks with this VMID not referenced in the config"),
281
+ }),
282
+ annotations: annotate("Delete VM", true, false),
283
+ }, async (a) => {
284
+ try {
285
+ const cfg = identity(await readConfig(pve, a.node, a.vmid), a.node, a.vmid, a.expectedName);
286
+ if (String(cfg.protection) === "1")
287
+ throw new Error(`VM ${a.vmid} has protection enabled — disable it in Proxmox first; nothing done`);
288
+ if (cfg.lock)
289
+ throw new Error(`VM ${a.vmid} is locked (${cfg.lock}) — nothing done`);
290
+ const status = await pve.get(`${qemu(a.node, a.vmid)}/status/current`);
291
+ if (status?.status !== "stopped")
292
+ throw new Error(`VM ${a.vmid} is ${status?.status ?? "in an unknown state"}: stop it first (pve_vm_action shutdown) — it is never stopped automatically; nothing done`);
293
+ if (status.ha?.managed && !a.purge)
294
+ throw new Error(`VM ${a.vmid} is managed by HA: pass purge=true to remove it from HA as part of the deletion, or remove the HA resource first; nothing done`);
295
+ const upid = await pve.del(qemu(a.node, a.vmid), { purge: a.purge ? 1 : 0, "destroy-unreferenced-disks": a.destroyUnreferencedDisks ? 1 : 0 });
296
+ const task = await wait(upid, TIMEOUTS.delete);
297
+ const left = await readConfig(pve, a.node, a.vmid);
298
+ const elsewhere = await clusterVmids(pve, a.vmid);
299
+ if (left || elsewhere.length)
300
+ throw new Error(`Delete task ${upid} ended OK but VMID ${a.vmid} still exists${elsewhere.length ? ` (${elsewhere.map((e) => e.node).join(", ")})` : ""} — check it`);
301
+ return textResult([
302
+ `# VM ${a.vmid} "${a.expectedName}" deleted from ${a.node}`,
303
+ "",
304
+ `- Task ${upid}: ${task.exitstatus}`,
305
+ `- purge: ${a.purge ? "yes (backup jobs, replication, HA cleaned)" : "no"} · unreferenced disks: ${a.destroyUnreferencedDisks ? "destroyed" : "kept"}`,
306
+ "- Verified: the VMID no longer exists in the cluster.",
307
+ ].join("\n"));
308
+ }
309
+ catch (err) {
310
+ return errResult(err);
311
+ }
312
+ });
313
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aidalinfo/proxmox-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Proxmox VE MCP server with read-only / full-access modes — cluster, nodes, QEMU VMs, LXC, storage, backups, tasks",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",