@koller-nexus/vps-ops-mcp 0.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/LICENSE +21 -0
- package/README.md +219 -0
- package/package.json +42 -0
- package/server.json +81 -0
- package/src/agents-md.test.ts +125 -0
- package/src/config.test.ts +101 -0
- package/src/config.ts +66 -0
- package/src/index.ts +73 -0
- package/src/mcp-registry.test.ts +58 -0
- package/src/opensource-governance.test.ts +113 -0
- package/src/ssh.test.ts +10 -0
- package/src/ssh.ts +108 -0
- package/src/tools/mutations.test.ts +60 -0
- package/src/tools/mutations.ts +209 -0
- package/src/tools/readonly.test.ts +60 -0
- package/src/tools/readonly.ts +264 -0
- package/src/validate.test.ts +62 -0
- package/src/validate.ts +48 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { VpsConfig } from "../config.js";
|
|
3
|
+
import { runSsh, shellQuote, type SshResult } from "../ssh.js";
|
|
4
|
+
import {
|
|
5
|
+
assertComposeDir,
|
|
6
|
+
assertContainerOrServiceName,
|
|
7
|
+
assertJournalUnit,
|
|
8
|
+
clampInt,
|
|
9
|
+
} from "../validate.js";
|
|
10
|
+
|
|
11
|
+
export type ToolHandler = (args: Record<string, unknown>) => Promise<SshResult>;
|
|
12
|
+
|
|
13
|
+
export interface ToolDef {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
inputSchema: z.ZodTypeAny;
|
|
17
|
+
handler: ToolHandler;
|
|
18
|
+
mutation?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveComposeDir(config: VpsConfig, dir?: string): string {
|
|
22
|
+
const raw = dir?.trim() || config.composeDir;
|
|
23
|
+
if (!raw) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"compose dir required: pass `dir` or set VPS_COMPOSE_DIR to an absolute path on the VPS"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return assertComposeDir(raw);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildReadonlyTools(config: VpsConfig): ToolDef[] {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
name: "vps_ping",
|
|
35
|
+
description:
|
|
36
|
+
"Health check: uname -a, uptime, and hostname on the VPS via SSH.",
|
|
37
|
+
inputSchema: z.object({}),
|
|
38
|
+
handler: async () =>
|
|
39
|
+
runSsh(
|
|
40
|
+
config,
|
|
41
|
+
"uname -a; echo '---'; uptime; echo '---'; hostname"
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "vps_resources",
|
|
46
|
+
description: "Disk (df -h), memory (free -h), and load average.",
|
|
47
|
+
inputSchema: z.object({}),
|
|
48
|
+
handler: async () =>
|
|
49
|
+
runSsh(
|
|
50
|
+
config,
|
|
51
|
+
"df -h; echo '---'; free -h; echo '---'; cat /proc/loadavg"
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "vps_journal",
|
|
56
|
+
description:
|
|
57
|
+
"Read systemd journal for an allowlisted unit (or *.service). N <= 500.",
|
|
58
|
+
inputSchema: z.object({
|
|
59
|
+
unit: z.string().describe("systemd unit, e.g. docker.service or sshd"),
|
|
60
|
+
n: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Number of lines (default 100, max 500)"),
|
|
65
|
+
}),
|
|
66
|
+
handler: async (args) => {
|
|
67
|
+
const unit = assertJournalUnit(String(args.unit));
|
|
68
|
+
const n = clampInt(Number(args.n ?? 100), 1, 500);
|
|
69
|
+
return runSsh(
|
|
70
|
+
config,
|
|
71
|
+
`journalctl -u ${shellQuote(unit)} --no-pager -n ${n}`
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "docker_ps",
|
|
77
|
+
description:
|
|
78
|
+
"List all Docker containers as JSON lines (fallback to table).",
|
|
79
|
+
inputSchema: z.object({}),
|
|
80
|
+
handler: async () =>
|
|
81
|
+
runSsh(
|
|
82
|
+
config,
|
|
83
|
+
"docker ps -a --format '{{json .}}' 2>/dev/null || docker ps -a"
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "docker_inspect",
|
|
88
|
+
description: "docker inspect on a container/image name.",
|
|
89
|
+
inputSchema: z.object({
|
|
90
|
+
name: z.string().describe("Container or image name"),
|
|
91
|
+
}),
|
|
92
|
+
handler: async (args) => {
|
|
93
|
+
const name = assertContainerOrServiceName(String(args.name));
|
|
94
|
+
return runSsh(config, `docker inspect ${shellQuote(name)}`);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "docker_logs",
|
|
99
|
+
description:
|
|
100
|
+
"docker logs --tail N --timestamps. Optional since. N <= 1000.",
|
|
101
|
+
inputSchema: z.object({
|
|
102
|
+
name: z.string().describe("Container name"),
|
|
103
|
+
n: z
|
|
104
|
+
.number()
|
|
105
|
+
.int()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Tail lines (default 200, max 1000)"),
|
|
108
|
+
since: z
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Optional --since value, e.g. 1h or timestamp"),
|
|
112
|
+
}),
|
|
113
|
+
handler: async (args) => {
|
|
114
|
+
const name = assertContainerOrServiceName(String(args.name));
|
|
115
|
+
const n = clampInt(Number(args.n ?? 200), 1, 1000);
|
|
116
|
+
let since = "";
|
|
117
|
+
if (args.since !== undefined && args.since !== "") {
|
|
118
|
+
const s = String(args.since);
|
|
119
|
+
if (!/^[a-zA-Z0-9:T.+_-]+$/.test(s)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
'Invalid since; use alphanumerics and : T . + _ - only (e.g. "1h", "2024-01-01T00:00:00")'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
since = ` --since ${shellQuote(s)}`;
|
|
125
|
+
}
|
|
126
|
+
return runSsh(
|
|
127
|
+
config,
|
|
128
|
+
`docker logs --tail ${n} --timestamps${since} ${shellQuote(name)}`
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "docker_stats",
|
|
134
|
+
description: "One-shot docker stats --no-stream.",
|
|
135
|
+
inputSchema: z.object({}),
|
|
136
|
+
handler: async () => runSsh(config, "docker stats --no-stream"),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "docker_service_ls",
|
|
140
|
+
description:
|
|
141
|
+
"List Docker Swarm services as JSON lines (fallback to table).",
|
|
142
|
+
inputSchema: z.object({}),
|
|
143
|
+
handler: async () =>
|
|
144
|
+
runSsh(
|
|
145
|
+
config,
|
|
146
|
+
"docker service ls --format '{{json .}}' 2>/dev/null || docker service ls"
|
|
147
|
+
),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "docker_node_ls",
|
|
151
|
+
description:
|
|
152
|
+
"List Docker Swarm nodes as JSON lines (fallback to table).",
|
|
153
|
+
inputSchema: z.object({}),
|
|
154
|
+
handler: async () =>
|
|
155
|
+
runSsh(
|
|
156
|
+
config,
|
|
157
|
+
"docker node ls --format '{{json .}}' 2>/dev/null || docker node ls"
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "compose_ps",
|
|
162
|
+
description: "docker compose ps in dir (arg or VPS_COMPOSE_DIR).",
|
|
163
|
+
inputSchema: z.object({
|
|
164
|
+
dir: z
|
|
165
|
+
.string()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("Absolute compose project dir on VPS"),
|
|
168
|
+
}),
|
|
169
|
+
handler: async (args) => {
|
|
170
|
+
const dir = resolveComposeDir(
|
|
171
|
+
config,
|
|
172
|
+
args.dir !== undefined ? String(args.dir) : undefined
|
|
173
|
+
);
|
|
174
|
+
return runSsh(config, `cd ${shellQuote(dir)} && docker compose ps`);
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "host_firewall",
|
|
179
|
+
description: "ufw status verbose (sudo -n, then fallback without sudo).",
|
|
180
|
+
inputSchema: z.object({}),
|
|
181
|
+
handler: async () =>
|
|
182
|
+
runSsh(
|
|
183
|
+
config,
|
|
184
|
+
"sudo -n ufw status verbose 2>/dev/null || ufw status verbose"
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "host_fail2ban",
|
|
189
|
+
description:
|
|
190
|
+
"fail2ban-client status; optional jail (default overall, or sshd).",
|
|
191
|
+
inputSchema: z.object({
|
|
192
|
+
jail: z
|
|
193
|
+
.string()
|
|
194
|
+
.optional()
|
|
195
|
+
.describe('Optional jail name, e.g. "sshd"'),
|
|
196
|
+
}),
|
|
197
|
+
handler: async (args) => {
|
|
198
|
+
let jailArg = "";
|
|
199
|
+
if (args.jail !== undefined && args.jail !== "") {
|
|
200
|
+
const jail = assertContainerOrServiceName(String(args.jail), "jail");
|
|
201
|
+
jailArg = ` ${shellQuote(jail)}`;
|
|
202
|
+
}
|
|
203
|
+
return runSsh(
|
|
204
|
+
config,
|
|
205
|
+
`sudo -n fail2ban-client status${jailArg}`
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "host_listen",
|
|
211
|
+
description:
|
|
212
|
+
"Listening TCP/UDP sockets (ss -lntup). sudo -n, then fallback without sudo.",
|
|
213
|
+
inputSchema: z.object({}),
|
|
214
|
+
handler: async () =>
|
|
215
|
+
runSsh(config, "sudo -n ss -lntup 2>/dev/null || ss -lntup"),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "host_failed_units",
|
|
219
|
+
description: "Failed systemd units (systemctl --failed --no-pager).",
|
|
220
|
+
inputSchema: z.object({}),
|
|
221
|
+
handler: async () =>
|
|
222
|
+
runSsh(config, "systemctl --failed --no-pager --full"),
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "host_top",
|
|
226
|
+
description: "Top 30 processes by memory (ps aux --sort=-%mem).",
|
|
227
|
+
inputSchema: z.object({}),
|
|
228
|
+
handler: async () =>
|
|
229
|
+
runSsh(config, "ps aux --sort=-%mem | head -n 31"),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "host_dmesg",
|
|
233
|
+
description:
|
|
234
|
+
"Kernel log tail via dmesg -T. N <= 200. sudo -n, then fallback without sudo.",
|
|
235
|
+
inputSchema: z.object({
|
|
236
|
+
n: z
|
|
237
|
+
.number()
|
|
238
|
+
.int()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe("Tail lines (default 100, max 200)"),
|
|
241
|
+
}),
|
|
242
|
+
handler: async (args) => {
|
|
243
|
+
const n = clampInt(Number(args.n ?? 100), 1, 200);
|
|
244
|
+
return runSsh(
|
|
245
|
+
config,
|
|
246
|
+
`(sudo -n dmesg -T 2>/dev/null || dmesg -T) | tail -n ${n}`
|
|
247
|
+
);
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "ssh_hardening_check",
|
|
252
|
+
description:
|
|
253
|
+
"Effective sshd settings: port, passwordauthentication, permitrootlogin, pubkeyauthentication.",
|
|
254
|
+
inputSchema: z.object({}),
|
|
255
|
+
handler: async () =>
|
|
256
|
+
runSsh(
|
|
257
|
+
config,
|
|
258
|
+
"sudo -n sshd -T | egrep '^(port|passwordauthentication|permitrootlogin|pubkeyauthentication) '"
|
|
259
|
+
),
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { resolveComposeDir };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assertComposeDir,
|
|
4
|
+
assertContainerOrServiceName,
|
|
5
|
+
assertJournalUnit,
|
|
6
|
+
clampInt,
|
|
7
|
+
} from "./validate.js";
|
|
8
|
+
|
|
9
|
+
test.each(["web", "nginx.1", "stack_svc-a"])(
|
|
10
|
+
"accepts container or service name %s",
|
|
11
|
+
(name) => {
|
|
12
|
+
expect(assertContainerOrServiceName(name)).toBe(name);
|
|
13
|
+
}
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
test.each(["", "-bad", "has space", "../etc", "a/b"])(
|
|
17
|
+
"rejects container or service name %s",
|
|
18
|
+
(name) => {
|
|
19
|
+
expect(() => assertContainerOrServiceName(name)).toThrow(/Invalid name/);
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
test("uses custom label in invalid name error", () => {
|
|
24
|
+
expect(() => assertContainerOrServiceName("bad name", "jail")).toThrow(
|
|
25
|
+
/Invalid jail/
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("accepts absolute compose dir", () => {
|
|
30
|
+
expect(assertComposeDir("/opt/stack")).toBe("/opt/stack");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test.each(["opt/stack", "/opt/stack with space", "/opt/$stack"])(
|
|
34
|
+
"rejects compose dir %s",
|
|
35
|
+
(path) => {
|
|
36
|
+
expect(() => assertComposeDir(path)).toThrow(/Invalid compose dir/);
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
test.each([
|
|
41
|
+
"docker",
|
|
42
|
+
"sshd.service",
|
|
43
|
+
"fail2ban",
|
|
44
|
+
"custom.service",
|
|
45
|
+
])("accepts journal unit %s", (unit) => {
|
|
46
|
+
expect(assertJournalUnit(unit)).toBe(unit);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.each(["nginx", "rm -rf", "docker.timer"])(
|
|
50
|
+
"rejects journal unit %s",
|
|
51
|
+
(unit) => {
|
|
52
|
+
expect(() => assertJournalUnit(unit)).toThrow(/not allowed/);
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
test("clampInt bounds and truncates", () => {
|
|
57
|
+
expect(clampInt(50, 1, 200)).toBe(50);
|
|
58
|
+
expect(clampInt(0, 1, 200)).toBe(1);
|
|
59
|
+
expect(clampInt(999, 1, 200)).toBe(200);
|
|
60
|
+
expect(clampInt(12.9, 1, 200)).toBe(12);
|
|
61
|
+
expect(clampInt(Number.NaN, 1, 200)).toBe(1);
|
|
62
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
2
|
+
const ABS_PATH_RE = /^\/[a-zA-Z0-9/_.-]+$/;
|
|
3
|
+
|
|
4
|
+
const JOURNAL_ALLOWLIST = new Set([
|
|
5
|
+
"docker",
|
|
6
|
+
"docker.service",
|
|
7
|
+
"ssh",
|
|
8
|
+
"sshd",
|
|
9
|
+
"sshd.service",
|
|
10
|
+
"fail2ban",
|
|
11
|
+
"fail2ban.service",
|
|
12
|
+
"ufw",
|
|
13
|
+
"cron",
|
|
14
|
+
"cron.service",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function assertContainerOrServiceName(name: string, label = "name"): string {
|
|
18
|
+
if (!NAME_RE.test(name)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Invalid ${label} "${name}". Must match /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertComposeDir(path: string): string {
|
|
27
|
+
if (!ABS_PATH_RE.test(path)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid compose dir "${path}". Must be absolute and match /^\\/[a-zA-Z0-9/_.-]+$/`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertJournalUnit(unit: string): string {
|
|
36
|
+
if (JOURNAL_ALLOWLIST.has(unit)) return unit;
|
|
37
|
+
if (NAME_RE.test(unit) && unit.endsWith(".service")) return unit;
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Unit "${unit}" not allowed. Use allowlist (docker, sshd, fail2ban, ufw, cron, …) or a safe name ending in .service`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function clampInt(n: number, min: number, max: number): number {
|
|
44
|
+
if (!Number.isFinite(n)) return min;
|
|
45
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { NAME_RE, ABS_PATH_RE };
|