@aidalinfo/proxmox-mcp 1.0.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 ADDED
@@ -0,0 +1,41 @@
1
+ # @aidalinfo/proxmox-mcp
2
+
3
+ MCP server for a Proxmox VE cluster, with two modes:
4
+
5
+ - **`readonly`** (default) — only read tools are exposed.
6
+ - **`full`** — read tools **plus** write tools (VM/LXC lifecycle, snapshots, migrate).
7
+
8
+ In `readonly` mode the write tools are never registered, so the model cannot call them.
9
+
10
+ ## Tools
11
+
12
+ **Read (both modes):** `pve_version`, `pve_cluster_status`, `pve_cluster_resources`,
13
+ `pve_nodes`, `pve_node_info`, `pve_vms`, `pve_vm_info`, `pve_containers`,
14
+ `pve_container_info`, `pve_storage`, `pve_storage_content`, `pve_tasks`, `pve_raw_get`.
15
+
16
+ **Write (`full` only):** `pve_vm_action`, `pve_lxc_action`, `pve_snapshot_create`,
17
+ `pve_snapshot_delete`, `pve_snapshot_rollback`, `pve_migrate`.
18
+
19
+ ## Environment
20
+
21
+ | Var | Required | Default | Role |
22
+ |---|---|---|---|
23
+ | `PROXMOX_HOST` | yes | — | e.g. `https://pve3.lan:8006` |
24
+ | `PROXMOX_TOKEN_ID` | yes | — | e.g. `root@pam!ia-access` |
25
+ | `PROXMOX_TOKEN_SECRET` | yes | — | token secret |
26
+ | `PROXMOX_MODE` | no | `readonly` | `readonly` \| `full` |
27
+ | `PROXMOX_TLS_INSECURE` | no | `false` | `true` to skip TLS verification (self-signed certs) |
28
+
29
+ ## Register with Claude Code
30
+
31
+ ```bash
32
+ claude mcp add proxmox --scope user \
33
+ -e PROXMOX_HOST=https://pve3.lan:8006 \
34
+ -e PROXMOX_TOKEN_ID='root@pam!ia-access' \
35
+ -e PROXMOX_TOKEN_SECRET=... \
36
+ -e PROXMOX_MODE=readonly \
37
+ -e PROXMOX_TLS_INSECURE=true \
38
+ -- node /ABS/PATH/cloud-mcp/packages/proxmox-mcp/dist/index.js
39
+ ```
40
+
41
+ For read-only use, create a token with the **PVEAuditor** role rather than `root@pam`.
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { registerTools, parseMode } from "../register.js";
3
+ function fakeServer() {
4
+ const names = [];
5
+ const server = { tool: (name) => { names.push(name); } };
6
+ return { server, names };
7
+ }
8
+ const fakeClient = {};
9
+ describe("parseMode", () => {
10
+ it("defaults to readonly when undefined", () => { expect(parseMode(undefined)).toBe("readonly"); });
11
+ it("treats unknown values as readonly", () => { expect(parseMode("banana")).toBe("readonly"); });
12
+ it("recognizes full", () => { expect(parseMode("full")).toBe("full"); });
13
+ });
14
+ describe("registerTools mode gating", () => {
15
+ it("readonly registers read tools but NOT write tools", () => {
16
+ const { server, names } = fakeServer();
17
+ registerTools(server, fakeClient, "readonly");
18
+ expect(names).toContain("pve_nodes");
19
+ expect(names).toContain("pve_vms");
20
+ expect(names).toContain("pve_raw_get");
21
+ expect(names).not.toContain("pve_vm_action");
22
+ expect(names).not.toContain("pve_snapshot_create");
23
+ expect(names).not.toContain("pve_migrate");
24
+ });
25
+ it("full registers read AND write tools", () => {
26
+ const { server, names } = fakeServer();
27
+ registerTools(server, fakeClient, "full");
28
+ expect(names).toContain("pve_nodes");
29
+ expect(names).toContain("pve_vm_action");
30
+ expect(names).toContain("pve_lxc_action");
31
+ expect(names).toContain("pve_snapshot_create");
32
+ expect(names).toContain("pve_snapshot_delete");
33
+ expect(names).toContain("pve_snapshot_rollback");
34
+ expect(names).toContain("pve_migrate");
35
+ });
36
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { validatePath, buildAuthHeader, ProxmoxClient } from "../proxmox-client.js";
3
+ describe("validatePath", () => {
4
+ it("rejects paths containing ..", () => {
5
+ expect(() => validatePath("/nodes/../secret")).toThrow();
6
+ });
7
+ it("rejects paths containing #", () => {
8
+ expect(() => validatePath("/nodes#frag")).toThrow();
9
+ });
10
+ it("rejects relative paths", () => {
11
+ expect(() => validatePath("nodes")).toThrow();
12
+ });
13
+ it("accepts a normal absolute path", () => {
14
+ expect(() => validatePath("/cluster/resources")).not.toThrow();
15
+ });
16
+ });
17
+ describe("buildAuthHeader", () => {
18
+ it("formats the PVEAPIToken header exactly", () => {
19
+ expect(buildAuthHeader("root@pam!ia-access", "secret-uuid")).toBe("PVEAPIToken=root@pam!ia-access=secret-uuid");
20
+ });
21
+ });
22
+ describe("ProxmoxClient", () => {
23
+ it("composes the api2/json base URL and strips a trailing slash", () => {
24
+ const c = new ProxmoxClient({ host: "https://pve3.lan:8006/", tokenId: "t", tokenSecret: "s" });
25
+ expect(c.baseUrl).toBe("https://pve3.lan:8006/api2/json");
26
+ });
27
+ });
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { ProxmoxClient } from "./proxmox-client.js";
6
+ import { registerTools, parseMode } from "./register.js";
7
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
8
+ function required(name) {
9
+ const val = process.env[name];
10
+ if (!val) {
11
+ process.stderr.write(`Missing required env var: ${name}\n`);
12
+ process.exit(1);
13
+ }
14
+ return val;
15
+ }
16
+ const mode = parseMode(process.env.PROXMOX_MODE);
17
+ if (process.env.PROXMOX_TLS_INSECURE === "true") {
18
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
19
+ process.stderr.write("⚠ PROXMOX_TLS_INSECURE=true — TLS certificate verification disabled\n");
20
+ }
21
+ const client = new ProxmoxClient({
22
+ host: required("PROXMOX_HOST"),
23
+ tokenId: required("PROXMOX_TOKEN_ID"),
24
+ tokenSecret: required("PROXMOX_TOKEN_SECRET"),
25
+ });
26
+ const server = new McpServer({ name: "proxmox", version: pkg.version });
27
+ registerTools(server, client, mode);
28
+ await server.connect(new StdioServerTransport());
29
+ process.stderr.write(`proxmox-mcp v${pkg.version} ready (mode: ${mode})\n`);
@@ -0,0 +1,72 @@
1
+ export const TIMEOUT_MS = 30_000;
2
+ const FORBIDDEN_PATH_PATTERNS = /[#]|\.\./;
3
+ export function validatePath(path) {
4
+ if (FORBIDDEN_PATH_PATTERNS.test(path)) {
5
+ throw new Error(`Unsafe API path rejected: "${path}" — must not contain ".." or "#"`);
6
+ }
7
+ if (!path.startsWith("/")) {
8
+ throw new Error(`API path must start with "/": "${path}"`);
9
+ }
10
+ }
11
+ export function buildAuthHeader(tokenId, tokenSecret) {
12
+ return `PVEAPIToken=${tokenId}=${tokenSecret}`;
13
+ }
14
+ export class ProxmoxClient {
15
+ baseUrl;
16
+ authHeader;
17
+ constructor(config) {
18
+ this.baseUrl = `${config.host.replace(/\/+$/, "")}/api2/json`;
19
+ this.authHeader = buildAuthHeader(config.tokenId, config.tokenSecret);
20
+ }
21
+ async request(method, path, opts = {}) {
22
+ validatePath(path);
23
+ let url = `${this.baseUrl}${path}`;
24
+ if (opts.query) {
25
+ const params = new URLSearchParams();
26
+ for (const [k, v] of Object.entries(opts.query)) {
27
+ if (v !== undefined && v !== null && v !== "")
28
+ params.set(k, String(v));
29
+ }
30
+ const qs = params.toString();
31
+ if (qs)
32
+ url += "?" + qs;
33
+ }
34
+ const headers = {
35
+ Authorization: this.authHeader,
36
+ Accept: "application/json",
37
+ };
38
+ let body;
39
+ if (opts.body) {
40
+ const form = new URLSearchParams();
41
+ for (const [k, v] of Object.entries(opts.body)) {
42
+ if (v !== undefined && v !== null && v !== "")
43
+ form.set(k, String(v));
44
+ }
45
+ body = form.toString();
46
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
47
+ }
48
+ const res = await fetch(url, {
49
+ method: method.toUpperCase(),
50
+ headers,
51
+ body,
52
+ signal: AbortSignal.timeout(TIMEOUT_MS),
53
+ });
54
+ const text = await res.text();
55
+ if (!res.ok) {
56
+ throw new Error(`Proxmox API ${method.toUpperCase()} ${path} → ${res.status}: ${text.slice(0, 500)}`);
57
+ }
58
+ if (!text)
59
+ return undefined;
60
+ const json = JSON.parse(text);
61
+ return (json && typeof json === "object" && "data" in json ? json.data : json);
62
+ }
63
+ get(path, query) {
64
+ return this.request("GET", path, { query });
65
+ }
66
+ post(path, body) {
67
+ return this.request("POST", path, { body });
68
+ }
69
+ del(path) {
70
+ return this.request("DELETE", path);
71
+ }
72
+ }
@@ -0,0 +1,24 @@
1
+ import { registerClusterTools } from "./tools/cluster.js";
2
+ import { registerVmTools } from "./tools/vms.js";
3
+ import { registerStorageTools } from "./tools/storage.js";
4
+ import { registerTaskTools } from "./tools/tasks.js";
5
+ import { registerRawTools } from "./tools/raw.js";
6
+ import { registerLifecycleTools } from "./tools/lifecycle.js";
7
+ import { registerSnapshotTools } from "./tools/snapshots.js";
8
+ import { registerMigrateTools } from "./tools/migrate.js";
9
+ export function parseMode(raw) {
10
+ return raw === "full" ? "full" : "readonly";
11
+ }
12
+ /** Read tools always register; write tools only in `full` mode. */
13
+ export function registerTools(server, client, mode) {
14
+ registerClusterTools(server, client);
15
+ registerVmTools(server, client);
16
+ registerStorageTools(server, client);
17
+ registerTaskTools(server, client);
18
+ registerRawTools(server, client);
19
+ if (mode === "full") {
20
+ registerLifecycleTools(server, client);
21
+ registerSnapshotTools(server, client);
22
+ registerMigrateTools(server, client);
23
+ }
24
+ }
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult, fmtBytes, fmtPct, fmtUptime } from "./utils.js";
3
+ export function registerClusterTools(server, pve) {
4
+ server.tool("pve_version", "Get the Proxmox VE version", {}, async () => {
5
+ try {
6
+ const v = await pve.get("/version");
7
+ return textResult(`# Proxmox VE\n- Version: ${v.version ?? "?"}\n- Release: ${v.release ?? "?"}\n- Repo ID: ${v.repoid ?? "?"}`);
8
+ }
9
+ catch (err) {
10
+ return errResult(err);
11
+ }
12
+ });
13
+ server.tool("pve_cluster_status", "Get cluster membership and quorum status", {}, async () => {
14
+ try {
15
+ const items = await pve.get("/cluster/status");
16
+ if (!items?.length)
17
+ return textResult("No cluster status returned (standalone node?).");
18
+ const lines = [`# Cluster status (${items.length})`, ""];
19
+ for (const e of items) {
20
+ if (e.type === "cluster")
21
+ lines.push(`## Cluster: ${e.name ?? "?"} — quorate: ${e.quorate ? "yes" : "no"} · nodes: ${e.nodes ?? "?"}`, "");
22
+ else
23
+ lines.push(`- node **${e.name ?? "?"}** — ${e.online ? "online" : "offline"}${e.ip ? ` · ${e.ip}` : ""}`);
24
+ }
25
+ return textResult(lines.join("\n"));
26
+ }
27
+ catch (err) {
28
+ return errResult(err);
29
+ }
30
+ });
31
+ server.tool("pve_cluster_resources", "One-shot overview of all cluster resources (VMs, nodes, storage)", { type: z.enum(["vm", "storage", "node"]).optional().describe("Filter by resource type") }, async ({ type }) => {
32
+ try {
33
+ const items = await pve.get("/cluster/resources", { type });
34
+ if (!items?.length)
35
+ return textResult("No resources found.");
36
+ const lines = [`# Cluster resources (${items.length})`, ""];
37
+ for (const r of items) {
38
+ if (r.type === "node")
39
+ lines.push(`- [node] **${r.node}** — ${r.status ?? "?"} · CPU ${fmtPct(r.cpu)} · mem ${fmtBytes(r.mem)}/${fmtBytes(r.maxmem)}`);
40
+ else if (r.type === "storage")
41
+ lines.push(`- [storage] **${r.storage}** @${r.node} — ${r.status ?? "?"} · ${fmtBytes(r.disk)}/${fmtBytes(r.maxdisk)}`);
42
+ else
43
+ lines.push(`- [${r.type}] **${r.name ?? r.vmid}** (vmid ${r.vmid}) @${r.node} — ${r.status ?? "?"} · CPU ${fmtPct(r.cpu)} · mem ${fmtBytes(r.mem)}`);
44
+ }
45
+ return textResult(lines.join("\n"));
46
+ }
47
+ catch (err) {
48
+ return errResult(err);
49
+ }
50
+ });
51
+ server.tool("pve_nodes", "List all nodes in the Proxmox cluster", {}, async () => {
52
+ try {
53
+ const items = await pve.get("/nodes");
54
+ if (!items?.length)
55
+ return textResult("No nodes found.");
56
+ const lines = [`# Nodes (${items.length})`, ""];
57
+ for (const n of items)
58
+ lines.push(`- **${n.node}** — ${n.status ?? "?"} · CPU ${fmtPct(n.cpu)} · mem ${fmtBytes(n.mem)}/${fmtBytes(n.maxmem)} · up ${fmtUptime(n.uptime)}`);
59
+ return textResult(lines.join("\n"));
60
+ }
61
+ catch (err) {
62
+ return errResult(err);
63
+ }
64
+ });
65
+ server.tool("pve_node_info", "Get detailed status of one node (CPU, RAM, uptime, load)", { node: z.string().describe("Node name, e.g. pve3") }, async ({ node }) => {
66
+ try {
67
+ const s = await pve.get(`/nodes/${node}/status`);
68
+ const lines = [
69
+ `# Node: ${node}`,
70
+ `- PVE version: ${s.pveversion ?? "?"}`,
71
+ `- Uptime: ${fmtUptime(s.uptime)}`,
72
+ `- CPU load: ${fmtPct(s.cpu)}`,
73
+ `- Load avg: ${s.loadavg?.join(", ") ?? "?"}`,
74
+ `- Memory: ${fmtBytes(s.memory?.used)}/${fmtBytes(s.memory?.total)}`,
75
+ `- Root FS: ${fmtBytes(s.rootfs?.used)}/${fmtBytes(s.rootfs?.total)}`,
76
+ ];
77
+ return textResult(lines.join("\n"));
78
+ }
79
+ catch (err) {
80
+ return errResult(err);
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult } from "./utils.js";
3
+ const ACTIONS = ["start", "stop", "shutdown", "reboot"];
4
+ export function registerLifecycleTools(server, pve) {
5
+ server.tool("pve_vm_action", "[WRITE] Change power state of a QEMU VM (start/stop/shutdown/reboot)", { node: z.string().describe("Node name"), vmid: z.number().int().describe("VM ID"), action: z.enum(ACTIONS).describe("Power action") }, async ({ node, vmid, action }) => {
6
+ try {
7
+ const upid = await pve.post(`/nodes/${node}/qemu/${vmid}/status/${action}`);
8
+ return textResult(`QEMU ${vmid} @${node}: ${action} requested.\nTask: ${upid}`);
9
+ }
10
+ catch (err) {
11
+ return errResult(err);
12
+ }
13
+ });
14
+ server.tool("pve_lxc_action", "[WRITE] Change power state of an LXC container (start/stop/shutdown/reboot)", { node: z.string().describe("Node name"), vmid: z.number().int().describe("Container ID"), action: z.enum(ACTIONS).describe("Power action") }, async ({ node, vmid, action }) => {
15
+ try {
16
+ const upid = await pve.post(`/nodes/${node}/lxc/${vmid}/status/${action}`);
17
+ return textResult(`LXC ${vmid} @${node}: ${action} requested.\nTask: ${upid}`);
18
+ }
19
+ catch (err) {
20
+ return errResult(err);
21
+ }
22
+ });
23
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult } from "./utils.js";
3
+ export function registerMigrateTools(server, pve) {
4
+ server.tool("pve_migrate", "[WRITE] Migrate a VM or container to another node", {
5
+ node: z.string().describe("Source node"),
6
+ kind: z.enum(["qemu", "lxc"]).describe("Guest kind"),
7
+ vmid: z.number().int().describe("Guest ID"),
8
+ target: z.string().describe("Target node"),
9
+ online: z.boolean().optional().describe("Online/live migration"),
10
+ }, async ({ node, kind, vmid, target, online }) => {
11
+ try {
12
+ const upid = await pve.post(`/nodes/${node}/${kind}/${vmid}/migrate`, { target, online: online ? 1 : undefined });
13
+ return textResult(`Migration of ${kind} ${vmid} from ${node} → ${target} requested.\nTask: ${upid}`);
14
+ }
15
+ catch (err) {
16
+ return errResult(err);
17
+ }
18
+ });
19
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult } from "./utils.js";
3
+ export function registerRawTools(server, pve) {
4
+ server.tool("pve_raw_get", "Perform a raw GET against any Proxmox API path under /api2/json (read-only). Example: /cluster/ha/status/current", {
5
+ path: z.string().describe('API path starting with "/", e.g. /nodes/pve3/qemu'),
6
+ query: z.record(z.string(), z.string()).optional().describe("Optional query string parameters"),
7
+ }, async ({ path, query }) => {
8
+ try {
9
+ const data = await pve.get(path, query);
10
+ return textResult("```json\n" + JSON.stringify(data, null, 2) + "\n```");
11
+ }
12
+ catch (err) {
13
+ return errResult(err);
14
+ }
15
+ });
16
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult } from "./utils.js";
3
+ const KIND = ["qemu", "lxc"];
4
+ export function registerSnapshotTools(server, pve) {
5
+ server.tool("pve_snapshot_create", "[WRITE] Create a snapshot of a VM or container", {
6
+ node: z.string().describe("Node name"),
7
+ kind: z.enum(KIND).describe("Guest kind"),
8
+ vmid: z.number().int().describe("Guest ID"),
9
+ snapname: z.string().describe("Snapshot name"),
10
+ description: z.string().optional().describe("Snapshot description"),
11
+ }, async ({ node, kind, vmid, snapname, description }) => {
12
+ try {
13
+ const upid = await pve.post(`/nodes/${node}/${kind}/${vmid}/snapshot`, { snapname, description });
14
+ return textResult(`Snapshot "${snapname}" creation requested for ${kind} ${vmid} @${node}.\nTask: ${upid}`);
15
+ }
16
+ catch (err) {
17
+ return errResult(err);
18
+ }
19
+ });
20
+ server.tool("pve_snapshot_delete", "[WRITE] Delete a snapshot of a VM or container", {
21
+ node: z.string().describe("Node name"),
22
+ kind: z.enum(KIND).describe("Guest kind"),
23
+ vmid: z.number().int().describe("Guest ID"),
24
+ snapname: z.string().describe("Snapshot name"),
25
+ }, async ({ node, kind, vmid, snapname }) => {
26
+ try {
27
+ const upid = await pve.del(`/nodes/${node}/${kind}/${vmid}/snapshot/${snapname}`);
28
+ return textResult(`Snapshot "${snapname}" deletion requested for ${kind} ${vmid} @${node}.\nTask: ${upid}`);
29
+ }
30
+ catch (err) {
31
+ return errResult(err);
32
+ }
33
+ });
34
+ server.tool("pve_snapshot_rollback", "[WRITE] Roll back a VM or container to a snapshot", {
35
+ node: z.string().describe("Node name"),
36
+ kind: z.enum(KIND).describe("Guest kind"),
37
+ vmid: z.number().int().describe("Guest ID"),
38
+ snapname: z.string().describe("Snapshot name"),
39
+ }, async ({ node, kind, vmid, snapname }) => {
40
+ try {
41
+ const upid = await pve.post(`/nodes/${node}/${kind}/${vmid}/snapshot/${snapname}/rollback`);
42
+ return textResult(`Rollback to "${snapname}" requested for ${kind} ${vmid} @${node}.\nTask: ${upid}`);
43
+ }
44
+ catch (err) {
45
+ return errResult(err);
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult, fmtBytes, nodeNames } from "./utils.js";
3
+ export function registerStorageTools(server, pve) {
4
+ server.tool("pve_storage", "List storage datastores and their usage", { node: z.string().optional().describe("Limit to one node") }, async ({ node }) => {
5
+ try {
6
+ const nodes = await nodeNames(pve, node);
7
+ const out = [];
8
+ const failed = [];
9
+ for (const n of nodes) {
10
+ try {
11
+ for (const s of (await pve.get(`/nodes/${n}/storage`)) ?? [])
12
+ out.push({ ...s, node: n });
13
+ }
14
+ catch {
15
+ failed.push(n);
16
+ }
17
+ }
18
+ if (!out.length && !failed.length)
19
+ return textResult("No storage found.");
20
+ const lines = [`# Storage (${out.length})`, ""];
21
+ for (const s of out)
22
+ lines.push(`- **${s.storage}** @${s.node} — ${s.type ?? "?"} · ${s.active ? "active" : "inactive"} · ${fmtBytes(s.used)}/${fmtBytes(s.total)} (avail ${fmtBytes(s.avail)})\n content: ${s.content ?? "?"}`);
23
+ if (failed.length)
24
+ lines.push("", `> Note: could not query node(s): ${failed.join(", ")} (permission denied or unreachable).`);
25
+ return textResult(lines.join("\n"));
26
+ }
27
+ catch (err) {
28
+ return errResult(err);
29
+ }
30
+ });
31
+ server.tool("pve_storage_content", "List content of a datastore (images, backups, ISOs)", {
32
+ node: z.string().describe("Node name"),
33
+ storage: z.string().describe("Storage ID"),
34
+ content: z.enum(["images", "backup", "iso", "vztmpl", "rootdir", "snippets"]).optional().describe("Filter by content type"),
35
+ }, async ({ node, storage, content }) => {
36
+ try {
37
+ const items = await pve.get(`/nodes/${node}/storage/${storage}/content`, { content });
38
+ if (!items?.length)
39
+ return textResult(`No content in ${storage} @${node}.`);
40
+ const lines = [`# Content of ${storage} @${node} (${items.length})`, ""];
41
+ for (const c of items)
42
+ lines.push(`- ${c.volid} — ${c.content ?? "?"}${c.vmid ? ` · vmid ${c.vmid}` : ""} · ${fmtBytes(c.size)}${c.format ? ` · ${c.format}` : ""}`);
43
+ return textResult(lines.join("\n"));
44
+ }
45
+ catch (err) {
46
+ return errResult(err);
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult, nodeNames } from "./utils.js";
3
+ export function registerTaskTools(server, pve) {
4
+ server.tool("pve_tasks", "List recent tasks (per node or whole cluster)", {
5
+ node: z.string().optional().describe("Limit to one node"),
6
+ limit: z.number().int().optional().describe("Max tasks per node (default 25)"),
7
+ }, async ({ node, limit }) => {
8
+ try {
9
+ const nodes = await nodeNames(pve, node);
10
+ const out = [];
11
+ const failed = [];
12
+ for (const n of nodes) {
13
+ try {
14
+ for (const t of (await pve.get(`/nodes/${n}/tasks`, { limit: limit ?? 25 })) ?? [])
15
+ out.push({ ...t, node: n });
16
+ }
17
+ catch {
18
+ failed.push(n);
19
+ }
20
+ }
21
+ if (!out.length && !failed.length)
22
+ return textResult("No recent tasks.");
23
+ const lines = [`# Recent tasks (${out.length})`, ""];
24
+ for (const t of out) {
25
+ const start = t.starttime ? new Date(t.starttime * 1000).toISOString() : "?";
26
+ lines.push(`- [${t.status ?? "running"}] ${t.type ?? "?"}${t.id ? ` (${t.id})` : ""} @${t.node} · ${t.user ?? "?"} · ${start}`);
27
+ }
28
+ if (failed.length)
29
+ lines.push("", `> Note: could not query node(s): ${failed.join(", ")} (permission denied or unreachable).`);
30
+ return textResult(lines.join("\n"));
31
+ }
32
+ catch (err) {
33
+ return errResult(err);
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,24 @@
1
+ /** Resolve the list of node names to query: the single given node, or all cluster nodes. */
2
+ export async function nodeNames(pve, node) {
3
+ if (node)
4
+ return [node];
5
+ const nodes = await pve.get("/nodes");
6
+ return (nodes ?? []).map((n) => n.node).filter((n) => Boolean(n));
7
+ }
8
+ export function textResult(text) {
9
+ return { content: [{ type: "text", text }] };
10
+ }
11
+ export function errResult(err) {
12
+ return {
13
+ content: [
14
+ { type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` },
15
+ ],
16
+ isError: true,
17
+ };
18
+ }
19
+ /** Bytes → "12.3 Gi". Returns "?" for null/undefined. */
20
+ export const fmtBytes = (n) => n === undefined || n === null ? "?" : `${(n / 1024 ** 3).toFixed(1)} Gi`;
21
+ /** Fraction (0..1) → "42%". Returns "?" for null/undefined. */
22
+ export const fmtPct = (v) => v === undefined || v === null ? "?" : `${(v * 100).toFixed(0)}%`;
23
+ /** Seconds → "5d 3h". Returns "?" for null/undefined. */
24
+ export const fmtUptime = (s) => s === undefined || s === null ? "?" : `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ import { textResult, errResult, fmtBytes, fmtPct, fmtUptime, nodeNames } from "./utils.js";
3
+ async function listGuests(pve, kind, node) {
4
+ const nodes = await nodeNames(pve, node);
5
+ const guests = [];
6
+ const failed = [];
7
+ for (const n of nodes) {
8
+ try {
9
+ const items = await pve.get(`/nodes/${n}/${kind}`);
10
+ for (const g of items ?? [])
11
+ guests.push({ ...g, node: n });
12
+ }
13
+ catch {
14
+ failed.push(n);
15
+ }
16
+ }
17
+ return { guests, failed };
18
+ }
19
+ export function registerVmTools(server, pve) {
20
+ server.tool("pve_vms", "List QEMU virtual machines across the cluster", { node: z.string().optional().describe("Limit to one node") }, async ({ node }) => {
21
+ try {
22
+ const { guests: vms, failed } = await listGuests(pve, "qemu", node);
23
+ if (!vms.length && !failed.length)
24
+ return textResult("No QEMU VMs found.");
25
+ const lines = [`# QEMU VMs (${vms.length})`, ""];
26
+ for (const v of vms)
27
+ lines.push(`- **${v.name ?? v.vmid}** (vmid ${v.vmid}) @${v.node} — ${v.status ?? "?"} · CPU ${fmtPct(v.cpu)} · mem ${fmtBytes(v.mem)}/${fmtBytes(v.maxmem)} · up ${fmtUptime(v.uptime)}`);
28
+ if (failed.length)
29
+ lines.push("", `> Note: could not query node(s): ${failed.join(", ")} (permission denied or unreachable).`);
30
+ return textResult(lines.join("\n"));
31
+ }
32
+ catch (err) {
33
+ return errResult(err);
34
+ }
35
+ });
36
+ server.tool("pve_vm_info", "Get config + live status of one QEMU VM", { node: z.string().describe("Node name"), vmid: z.number().int().describe("VM ID") }, async ({ node, vmid }) => {
37
+ try {
38
+ const [config, status] = await Promise.all([
39
+ pve.get(`/nodes/${node}/qemu/${vmid}/config`),
40
+ pve.get(`/nodes/${node}/qemu/${vmid}/status/current`),
41
+ ]);
42
+ const lines = [
43
+ `# QEMU VM ${vmid} @${node}`,
44
+ `- Name: ${config.name ?? "?"}`,
45
+ `- Status: ${status.status ?? "?"}`,
46
+ `- Cores: ${config.cores ?? "?"} · Memory: ${config.memory ?? "?"} MB`,
47
+ `- OS type: ${config.ostype ?? "?"}`,
48
+ "",
49
+ "## Full config",
50
+ "```json",
51
+ JSON.stringify(config, null, 2),
52
+ "```",
53
+ ];
54
+ return textResult(lines.join("\n"));
55
+ }
56
+ catch (err) {
57
+ return errResult(err);
58
+ }
59
+ });
60
+ server.tool("pve_containers", "List LXC containers across the cluster", { node: z.string().optional().describe("Limit to one node") }, async ({ node }) => {
61
+ try {
62
+ const { guests: cts, failed } = await listGuests(pve, "lxc", node);
63
+ if (!cts.length && !failed.length)
64
+ return textResult("No LXC containers found.");
65
+ const lines = [`# LXC containers (${cts.length})`, ""];
66
+ for (const c of cts)
67
+ lines.push(`- **${c.name ?? c.vmid}** (vmid ${c.vmid}) @${c.node} — ${c.status ?? "?"} · CPU ${fmtPct(c.cpu)} · mem ${fmtBytes(c.mem)}/${fmtBytes(c.maxmem)} · up ${fmtUptime(c.uptime)}`);
68
+ if (failed.length)
69
+ lines.push("", `> Note: could not query node(s): ${failed.join(", ")} (permission denied or unreachable).`);
70
+ return textResult(lines.join("\n"));
71
+ }
72
+ catch (err) {
73
+ return errResult(err);
74
+ }
75
+ });
76
+ server.tool("pve_container_info", "Get config + live status of one LXC container", { node: z.string().describe("Node name"), vmid: z.number().int().describe("Container ID") }, async ({ node, vmid }) => {
77
+ try {
78
+ const [config, status] = await Promise.all([
79
+ pve.get(`/nodes/${node}/lxc/${vmid}/config`),
80
+ pve.get(`/nodes/${node}/lxc/${vmid}/status/current`),
81
+ ]);
82
+ const lines = [
83
+ `# LXC container ${vmid} @${node}`,
84
+ `- Hostname: ${config.hostname ?? "?"}`,
85
+ `- Status: ${status.status ?? "?"}`,
86
+ `- Cores: ${config.cores ?? "?"} · Memory: ${config.memory ?? "?"} MB · Swap: ${config.swap ?? "?"} MB`,
87
+ `- OS template: ${config.ostype ?? "?"}`,
88
+ "",
89
+ "## Full config",
90
+ "```json",
91
+ JSON.stringify(config, null, 2),
92
+ "```",
93
+ ];
94
+ return textResult(lines.join("\n"));
95
+ }
96
+ catch (err) {
97
+ return errResult(err);
98
+ }
99
+ });
100
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@aidalinfo/proxmox-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Proxmox VE MCP server with read-only / full-access modes — cluster, nodes, QEMU VMs, LXC, storage, backups, tasks",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "proxmox-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Killian Stein / Aidalinfo",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/aidalinfo/cloud-mcp.git",
22
+ "directory": "packages/proxmox-mcp"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "keywords": ["mcp", "mcp-server", "proxmox", "pve", "virtualization", "qemu", "lxc", "typescript"],
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "prepack": "npm run build",
31
+ "start": "node dist/index.js",
32
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.27.0",
36
+ "zod": "^4.4.3"
37
+ },
38
+ "devDependencies": {
39
+ "@jest/globals": "^30.3.0",
40
+ "@types/node": "^22.0.0",
41
+ "jest": "^30.3.0",
42
+ "ts-jest": "^29.4.6",
43
+ "typescript": "^5.7.0"
44
+ }
45
+ }