@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/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { loadConfig } from "./config.js";
5
+ import { buildReadonlyTools, type ToolDef } from "./tools/readonly.js";
6
+ import { buildMutationTools } from "./tools/mutations.js";
7
+ import type { SshResult } from "./ssh.js";
8
+
9
+ function resultToText(result: SshResult): string {
10
+ return JSON.stringify(result, null, 2);
11
+ }
12
+
13
+ function registerAll(server: McpServer, tools: ToolDef[]): void {
14
+ for (const tool of tools) {
15
+ server.registerTool(
16
+ tool.name,
17
+ {
18
+ description: tool.description,
19
+ inputSchema: tool.inputSchema,
20
+ },
21
+ async (args: unknown) => {
22
+ try {
23
+ const parsed = tool.inputSchema.parse(args ?? {});
24
+ const result = await tool.handler(parsed as Record<string, unknown>);
25
+ return {
26
+ content: [{ type: "text" as const, text: resultToText(result) }],
27
+ isError: result.exit_code !== 0,
28
+ };
29
+ } catch (err) {
30
+ const msg = err instanceof Error ? err.message : String(err);
31
+ const failed: SshResult = {
32
+ exit_code: 1,
33
+ stdout: "",
34
+ stderr: msg,
35
+ duration_ms: 0,
36
+ truncated: false,
37
+ };
38
+ return {
39
+ content: [{ type: "text" as const, text: resultToText(failed) }],
40
+ isError: true,
41
+ };
42
+ }
43
+ }
44
+ );
45
+ }
46
+ }
47
+
48
+ async function main(): Promise<void> {
49
+ let config;
50
+ try {
51
+ config = loadConfig();
52
+ } catch (err) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ console.error(`[vps-ops] Fatal config error: ${msg}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const tools: ToolDef[] = [
59
+ ...buildReadonlyTools(config),
60
+ ...buildMutationTools(config),
61
+ ];
62
+
63
+ const server = new McpServer({ name: "vps-ops", version: "0.1.0" });
64
+ registerAll(server, tools);
65
+
66
+ const transport = new StdioServerTransport();
67
+ await server.connect(transport);
68
+ }
69
+
70
+ main().catch((err) => {
71
+ console.error("[vps-ops] Unhandled error:", err);
72
+ process.exit(1);
73
+ });
@@ -0,0 +1,58 @@
1
+ import { expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+
4
+ const root = resolve(import.meta.dir, "..");
5
+ const packagePath = resolve(root, "package.json");
6
+ const serverJsonPath = resolve(root, "server.json");
7
+ const versionPath = resolve(root, "VERSION");
8
+
9
+ const NPM_NAME = "@koller-nexus/vps-ops-mcp";
10
+ const MCP_NAME = "io.github.koller-nexus/vps-ops-mcp";
11
+ const REPO_URL = "https://github.com/koller-nexus/vps-ops-mcp";
12
+
13
+ const REQUIRED_ENV = [
14
+ "VPS_SSH_KEY_PATH",
15
+ "VPS_HOST",
16
+ "VPS_USER",
17
+ "VPS_PORT",
18
+ "VPS_ALLOW_MUTATIONS",
19
+ ] as const;
20
+
21
+ test("package.json is a public npm package with matching mcpName", async () => {
22
+ const pkg = await Bun.file(packagePath).json();
23
+ const version = (await Bun.file(versionPath).text()).trim();
24
+
25
+ expect(pkg.private).toBeUndefined();
26
+ expect(pkg.name).toBe(NPM_NAME);
27
+ expect(pkg.mcpName).toBe(MCP_NAME);
28
+ expect(pkg.version).toBe(version);
29
+ expect(pkg.repository?.url).toContain(REPO_URL);
30
+ expect(pkg.bin?.["vps-ops-mcp"]).toBe("src/index.ts");
31
+ expect(pkg.publishConfig?.access).toBe("public");
32
+ });
33
+
34
+ test("server.json matches package.json and lists required env names", async () => {
35
+ const pkg = await Bun.file(packagePath).json();
36
+ const server = await Bun.file(serverJsonPath).json();
37
+
38
+ expect(await Bun.file(serverJsonPath).exists()).toBe(true);
39
+ expect(server.name).toBe(pkg.mcpName);
40
+ expect(server.version).toBe(pkg.version);
41
+ expect(server.description.length).toBeLessThanOrEqual(100);
42
+
43
+ const npm = server.packages?.[0];
44
+ expect(npm.registryType).toBe("npm");
45
+ expect(npm.identifier).toBe(pkg.name);
46
+ expect(npm.version).toBe(pkg.version);
47
+ expect(npm.transport?.type).toBe("stdio");
48
+ expect(npm.runtimeHint).toBe("bunx");
49
+
50
+ const envNames = (npm.environmentVariables ?? []).map((e: { name: string }) => e.name);
51
+ for (const name of REQUIRED_ENV) {
52
+ expect(envNames).toContain(name);
53
+ }
54
+
55
+ const keyPath = npm.environmentVariables.find((e: { name: string }) => e.name === "VPS_SSH_KEY_PATH");
56
+ expect(keyPath.isRequired).toBe(true);
57
+ expect(keyPath.isSecret).toBe(true);
58
+ });
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+
4
+ const root = resolve(import.meta.dir, "..");
5
+ const contributingPath = resolve(root, "CONTRIBUTING.md");
6
+ const issuePath = resolve(root, "ISSUE.md");
7
+ const licensePath = resolve(root, "LICENSE");
8
+ const workflowPath = resolve(root, ".github", "workflows", "pull_request.yml");
9
+ const envExamplePath = resolve(root, ".env.example");
10
+ const readmePath = resolve(root, "README.md");
11
+
12
+ const CONTRIBUTING_HEADINGS = [
13
+ "# Contributing",
14
+ "## Branch names",
15
+ "## Pull requests",
16
+ "## Verification",
17
+ "## Issues",
18
+ ] as const;
19
+
20
+ const CONTRIBUTING_TOKENS = [
21
+ "feature/",
22
+ "fix/",
23
+ "refactor/",
24
+ "main",
25
+ "pull request",
26
+ "ISSUE.md",
27
+ "bun test",
28
+ "bun run build",
29
+ "Conventional Commits",
30
+ ] as const;
31
+
32
+ const ISSUE_HEADINGS = [
33
+ "# Filing an issue",
34
+ "## Summary",
35
+ "## Type",
36
+ "## Expected versus actual",
37
+ "## Reproduction or acceptance",
38
+ "## Environment",
39
+ ] as const;
40
+
41
+ const ISSUE_TOKENS = ["bug", "feature", "CONTRIBUTING.md"] as const;
42
+
43
+ const LICENSE_TOKENS = [
44
+ "MIT License",
45
+ "Permission is hereby granted, free of charge",
46
+ "2026",
47
+ 'THE SOFTWARE IS PROVIDED "AS IS"',
48
+ ] as const;
49
+
50
+ const LIVE_HOST = "64.181.163.182";
51
+
52
+ async function loadRequired(path: string, label: string): Promise<string> {
53
+ const file = Bun.file(path);
54
+ if (!(await file.exists())) {
55
+ throw new Error(`${label} is missing`);
56
+ }
57
+ return file.text();
58
+ }
59
+
60
+ function expectHeadings(text: string, headings: readonly string[]): void {
61
+ let cursor = -1;
62
+ for (const heading of headings) {
63
+ const at = text.indexOf(heading);
64
+ expect(at, heading).toBeGreaterThan(cursor);
65
+ cursor = at;
66
+ }
67
+ }
68
+
69
+ function expectTokens(text: string, tokens: readonly string[]): void {
70
+ for (const token of tokens) {
71
+ expect(text.includes(token), token).toBe(true);
72
+ }
73
+ }
74
+
75
+ test("root contribution files exist", async () => {
76
+ expect(await Bun.file(contributingPath).exists()).toBe(true);
77
+ expect(await Bun.file(issuePath).exists()).toBe(true);
78
+ expect(await Bun.file(licensePath).exists()).toBe(true);
79
+ });
80
+
81
+ test("pull request workflow runs test then build", async () => {
82
+ const workflow = await loadRequired(workflowPath, "pull_request.yml");
83
+ expect(workflow).toContain("pull_request");
84
+ expect(workflow).toContain("Test and build");
85
+ const testAt = workflow.indexOf("bun test");
86
+ const buildAt = workflow.indexOf("bun run build");
87
+ expect(testAt).toBeGreaterThan(-1);
88
+ expect(buildAt).toBeGreaterThan(testAt);
89
+ });
90
+
91
+ test("CONTRIBUTING.md matches the contribution contract", async () => {
92
+ const text = await loadRequired(contributingPath, "CONTRIBUTING.md");
93
+ expectHeadings(text, CONTRIBUTING_HEADINGS);
94
+ expectTokens(text, CONTRIBUTING_TOKENS);
95
+ });
96
+
97
+ test("ISSUE.md matches the issue-filing contract", async () => {
98
+ const text = await loadRequired(issuePath, "ISSUE.md");
99
+ expectHeadings(text, ISSUE_HEADINGS);
100
+ expectTokens(text, ISSUE_TOKENS);
101
+ });
102
+
103
+ test("LICENSE is MIT", async () => {
104
+ const text = await loadRequired(licensePath, "LICENSE");
105
+ expectTokens(text, LICENSE_TOKENS);
106
+ });
107
+
108
+ test("example env and README omit the live host address", async () => {
109
+ const envExample = await loadRequired(envExamplePath, ".env.example");
110
+ const readme = await loadRequired(readmePath, "README.md");
111
+ expect(envExample.includes(LIVE_HOST)).toBe(false);
112
+ expect(readme.includes(LIVE_HOST)).toBe(false);
113
+ });
@@ -0,0 +1,10 @@
1
+ import { test, expect } from "bun:test";
2
+ import { shellQuote } from "./ssh.js";
3
+
4
+ test("quotes a simple token", () => {
5
+ expect(shellQuote("nginx")).toBe("'nginx'");
6
+ });
7
+
8
+ test("escapes single quotes for POSIX shells", () => {
9
+ expect(shellQuote("it's")).toBe(`'it'\\''s'`);
10
+ });
package/src/ssh.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { VpsConfig } from "./config.js";
3
+
4
+ export interface SshResult {
5
+ exit_code: number;
6
+ stdout: string;
7
+ stderr: string;
8
+ duration_ms: number;
9
+ truncated: boolean;
10
+ }
11
+
12
+ function truncate(text: string, maxBytes: number): { text: string; truncated: boolean } {
13
+ const buf = Buffer.from(text, "utf8");
14
+ if (buf.length <= maxBytes) return { text, truncated: false };
15
+ const sliced = buf.subarray(0, maxBytes).toString("utf8");
16
+ return {
17
+ text: sliced + `\n…[truncated ${buf.length - maxBytes} bytes]`,
18
+ truncated: true,
19
+ };
20
+ }
21
+
22
+ export function runSsh(
23
+ config: VpsConfig,
24
+ remoteCommand: string,
25
+ timeoutMs?: number
26
+ ): Promise<SshResult> {
27
+ const timeout = timeoutMs ?? config.commandTimeoutMs;
28
+ const started = Date.now();
29
+
30
+ const args = [
31
+ "-i",
32
+ config.sshKeyPath,
33
+ "-o",
34
+ "BatchMode=yes",
35
+ "-o",
36
+ "IdentitiesOnly=yes",
37
+ "-o",
38
+ "StrictHostKeyChecking=accept-new",
39
+ "-p",
40
+ String(config.port),
41
+ `${config.user}@${config.host}`,
42
+ "--",
43
+ remoteCommand,
44
+ ];
45
+
46
+ return new Promise((resolve) => {
47
+ const child = spawn("ssh", args, {
48
+ env: process.env,
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ });
51
+
52
+ let stdout = "";
53
+ let stderr = "";
54
+ let killed = false;
55
+
56
+ const timer = setTimeout(() => {
57
+ killed = true;
58
+ child.kill("SIGKILL");
59
+ }, timeout);
60
+
61
+ child.stdout.on("data", (chunk: Buffer) => {
62
+ stdout += chunk.toString("utf8");
63
+ });
64
+ child.stderr.on("data", (chunk: Buffer) => {
65
+ stderr += chunk.toString("utf8");
66
+ });
67
+
68
+ child.on("error", (err) => {
69
+ clearTimeout(timer);
70
+ const duration_ms = Date.now() - started;
71
+ const out = truncate(stdout, config.logMaxBytes);
72
+ const errT = truncate(
73
+ stderr + (stderr ? "\n" : "") + String(err),
74
+ config.logMaxBytes
75
+ );
76
+ resolve({
77
+ exit_code: 127,
78
+ stdout: out.text,
79
+ stderr: errT.text,
80
+ duration_ms,
81
+ truncated: out.truncated || errT.truncated,
82
+ });
83
+ });
84
+
85
+ child.on("close", (code) => {
86
+ clearTimeout(timer);
87
+ const duration_ms = Date.now() - started;
88
+ if (killed) {
89
+ stderr +=
90
+ (stderr ? "\n" : "") +
91
+ `Command timed out after ${timeout}ms and was killed.`;
92
+ }
93
+ const out = truncate(stdout, config.logMaxBytes);
94
+ const errT = truncate(stderr, config.logMaxBytes);
95
+ resolve({
96
+ exit_code: killed ? 124 : code ?? 1,
97
+ stdout: out.text,
98
+ stderr: errT.text,
99
+ duration_ms,
100
+ truncated: out.truncated || errT.truncated,
101
+ });
102
+ });
103
+ });
104
+ }
105
+
106
+ export function shellQuote(s: string): string {
107
+ return `'${s.replace(/'/g, `'\\''`)}'`;
108
+ }
@@ -0,0 +1,60 @@
1
+ import { test, expect } from "bun:test";
2
+ import type { VpsConfig } from "../config.js";
3
+ import { buildMutationTools } from "./mutations.js";
4
+
5
+ const disabled: VpsConfig = {
6
+ host: "127.0.0.1",
7
+ user: "ubuntu",
8
+ port: 22,
9
+ sshKeyPath: "/tmp/unused-key",
10
+ commandTimeoutMs: 30_000,
11
+ logMaxBytes: 200_000,
12
+ allowMutations: false,
13
+ };
14
+
15
+ const enabled: VpsConfig = { ...disabled, allowMutations: true };
16
+
17
+ const mutationNames = [
18
+ "docker_restart",
19
+ "docker_stop",
20
+ "docker_start",
21
+ "compose_up",
22
+ "compose_restart",
23
+ "compose_pull_up",
24
+ "docker_rm",
25
+ "disk_cleanup_docker",
26
+ ] as const;
27
+
28
+ test.each([...mutationNames])("exposes mutation tool %s", (name) => {
29
+ const tool = buildMutationTools(enabled).find((t) => t.name === name);
30
+ expect(tool).toBeDefined();
31
+ expect(tool?.mutation).toBe(true);
32
+ });
33
+
34
+ test("refuses mutations when VPS_ALLOW_MUTATIONS is false", async () => {
35
+ const tool = buildMutationTools(disabled).find((t) => t.name === "docker_restart");
36
+ await expect(tool!.handler({ name: "web", confirm: true })).rejects.toThrow(
37
+ /Mutations disabled/
38
+ );
39
+ });
40
+
41
+ test("refuses mutations without confirm:true", async () => {
42
+ const tool = buildMutationTools(enabled).find((t) => t.name === "docker_stop");
43
+ await expect(tool!.handler({ name: "web", confirm: false })).rejects.toThrow(
44
+ /Mutation refused/
45
+ );
46
+ });
47
+
48
+ test("docker_rm requires force_name to match name", async () => {
49
+ const tool = buildMutationTools(enabled).find((t) => t.name === "docker_rm");
50
+ await expect(
51
+ tool!.handler({ name: "web", force_name: "other", confirm: true })
52
+ ).rejects.toThrow(/force_name/);
53
+ });
54
+
55
+ test("rejects unsafe docker mutation names before SSH", async () => {
56
+ const tool = buildMutationTools(enabled).find((t) => t.name === "docker_start");
57
+ await expect(
58
+ tool!.handler({ name: "web; rm -rf /", confirm: true })
59
+ ).rejects.toThrow(/Invalid name/);
60
+ });
@@ -0,0 +1,209 @@
1
+ import { z } from "zod";
2
+ import type { VpsConfig } from "../config.js";
3
+ import { runSsh, shellQuote, type SshResult } from "../ssh.js";
4
+ import { assertContainerOrServiceName } from "../validate.js";
5
+ import { resolveComposeDir, type ToolDef } from "./readonly.js";
6
+
7
+ function requireConfirm(args: Record<string, unknown>): void {
8
+ if (args.confirm !== true) {
9
+ throw new Error(
10
+ "Mutation refused: set confirm:true to proceed (and ensure VPS_ALLOW_MUTATIONS is not false)."
11
+ );
12
+ }
13
+ }
14
+
15
+ function requireMutationsAllowed(config: VpsConfig): void {
16
+ if (!config.allowMutations) {
17
+ throw new Error(
18
+ "Mutations disabled: VPS_ALLOW_MUTATIONS=false. Only readonly tools are available."
19
+ );
20
+ }
21
+ }
22
+
23
+ async function gated(
24
+ config: VpsConfig,
25
+ args: Record<string, unknown>,
26
+ fn: () => Promise<SshResult>
27
+ ): Promise<SshResult> {
28
+ requireMutationsAllowed(config);
29
+ requireConfirm(args);
30
+ return fn();
31
+ }
32
+
33
+ const confirmField = z
34
+ .literal(true)
35
+ .describe("Must be true to execute this mutation");
36
+
37
+ export function buildMutationTools(config: VpsConfig): ToolDef[] {
38
+ return [
39
+ {
40
+ name: "docker_restart",
41
+ description: "docker restart NAME. Requires confirm:true.",
42
+ mutation: true,
43
+ inputSchema: z.object({ name: z.string(), confirm: confirmField }),
44
+ handler: async (args) =>
45
+ gated(config, args, () => {
46
+ const name = assertContainerOrServiceName(String(args.name));
47
+ return runSsh(config, `docker restart ${shellQuote(name)}`);
48
+ }),
49
+ },
50
+ {
51
+ name: "docker_stop",
52
+ description: "docker stop NAME. Requires confirm:true.",
53
+ mutation: true,
54
+ inputSchema: z.object({ name: z.string(), confirm: confirmField }),
55
+ handler: async (args) =>
56
+ gated(config, args, () => {
57
+ const name = assertContainerOrServiceName(String(args.name));
58
+ return runSsh(config, `docker stop ${shellQuote(name)}`);
59
+ }),
60
+ },
61
+ {
62
+ name: "docker_start",
63
+ description: "docker start NAME. Requires confirm:true.",
64
+ mutation: true,
65
+ inputSchema: z.object({ name: z.string(), confirm: confirmField }),
66
+ handler: async (args) =>
67
+ gated(config, args, () => {
68
+ const name = assertContainerOrServiceName(String(args.name));
69
+ return runSsh(config, `docker start ${shellQuote(name)}`);
70
+ }),
71
+ },
72
+ {
73
+ name: "compose_up",
74
+ description:
75
+ "docker compose up -d [services…]. Requires confirm:true. dir or VPS_COMPOSE_DIR.",
76
+ mutation: true,
77
+ inputSchema: z.object({
78
+ dir: z.string().optional(),
79
+ services: z.array(z.string()).optional(),
80
+ confirm: confirmField,
81
+ }),
82
+ handler: async (args) =>
83
+ gated(config, args, () => {
84
+ const dir = resolveComposeDir(
85
+ config,
86
+ args.dir !== undefined ? String(args.dir) : undefined
87
+ );
88
+ const services = Array.isArray(args.services)
89
+ ? (args.services as string[]).map((s) =>
90
+ assertContainerOrServiceName(s, "service")
91
+ )
92
+ : [];
93
+ const svc =
94
+ services.length > 0
95
+ ? " " + services.map(shellQuote).join(" ")
96
+ : "";
97
+ return runSsh(
98
+ config,
99
+ `cd ${shellQuote(dir)} && docker compose up -d${svc}`
100
+ );
101
+ }),
102
+ },
103
+ {
104
+ name: "compose_restart",
105
+ description:
106
+ "docker compose restart [services…]. Requires confirm:true.",
107
+ mutation: true,
108
+ inputSchema: z.object({
109
+ dir: z.string().optional(),
110
+ services: z.array(z.string()).optional(),
111
+ confirm: confirmField,
112
+ }),
113
+ handler: async (args) =>
114
+ gated(config, args, () => {
115
+ const dir = resolveComposeDir(
116
+ config,
117
+ args.dir !== undefined ? String(args.dir) : undefined
118
+ );
119
+ const services = Array.isArray(args.services)
120
+ ? (args.services as string[]).map((s) =>
121
+ assertContainerOrServiceName(s, "service")
122
+ )
123
+ : [];
124
+ const svc =
125
+ services.length > 0
126
+ ? " " + services.map(shellQuote).join(" ")
127
+ : "";
128
+ return runSsh(
129
+ config,
130
+ `cd ${shellQuote(dir)} && docker compose restart${svc}`
131
+ );
132
+ }),
133
+ },
134
+ {
135
+ name: "compose_pull_up",
136
+ description:
137
+ "docker compose pull then up -d [services…]. Requires confirm:true.",
138
+ mutation: true,
139
+ inputSchema: z.object({
140
+ dir: z.string().optional(),
141
+ services: z.array(z.string()).optional(),
142
+ confirm: confirmField,
143
+ }),
144
+ handler: async (args) =>
145
+ gated(config, args, () => {
146
+ const dir = resolveComposeDir(
147
+ config,
148
+ args.dir !== undefined ? String(args.dir) : undefined
149
+ );
150
+ const services = Array.isArray(args.services)
151
+ ? (args.services as string[]).map((s) =>
152
+ assertContainerOrServiceName(s, "service")
153
+ )
154
+ : [];
155
+ const svc =
156
+ services.length > 0
157
+ ? " " + services.map(shellQuote).join(" ")
158
+ : "";
159
+ return runSsh(
160
+ config,
161
+ `cd ${shellQuote(dir)} && docker compose pull${svc} && docker compose up -d${svc}`
162
+ );
163
+ }),
164
+ },
165
+ {
166
+ name: "docker_rm",
167
+ description:
168
+ "docker rm -f NAME. Requires confirm:true AND force_name === name.",
169
+ mutation: true,
170
+ inputSchema: z.object({
171
+ name: z.string(),
172
+ force_name: z
173
+ .string()
174
+ .describe("Must equal name exactly (double confirmation)"),
175
+ confirm: confirmField,
176
+ }),
177
+ handler: async (args) =>
178
+ gated(config, args, () => {
179
+ const name = assertContainerOrServiceName(String(args.name));
180
+ const forceName = String(args.force_name ?? "");
181
+ if (forceName !== name) {
182
+ throw new Error(
183
+ `docker_rm refused: force_name ("${forceName}") must equal name ("${name}")`
184
+ );
185
+ }
186
+ return runSsh(config, `docker rm -f ${shellQuote(name)}`);
187
+ }),
188
+ },
189
+ {
190
+ name: "disk_cleanup_docker",
191
+ description:
192
+ "docker system prune -f; volumes only if confirm_volumes:true. Requires confirm:true.",
193
+ mutation: true,
194
+ inputSchema: z.object({
195
+ confirm: confirmField,
196
+ confirm_volumes: z
197
+ .boolean()
198
+ .optional()
199
+ .describe("If true, also prune unused volumes (--volumes)"),
200
+ }),
201
+ handler: async (args) =>
202
+ gated(config, args, () => {
203
+ const volumes =
204
+ args.confirm_volumes === true ? " --volumes" : "";
205
+ return runSsh(config, `docker system prune -f${volumes}`);
206
+ }),
207
+ },
208
+ ];
209
+ }
@@ -0,0 +1,60 @@
1
+ import { test, expect } from "bun:test";
2
+ import type { VpsConfig } from "../config.js";
3
+ import { buildReadonlyTools, resolveComposeDir } from "./readonly.js";
4
+
5
+ const config: VpsConfig = {
6
+ host: "127.0.0.1",
7
+ user: "ubuntu",
8
+ port: 22,
9
+ sshKeyPath: "/tmp/unused-key",
10
+ commandTimeoutMs: 30_000,
11
+ logMaxBytes: 200_000,
12
+ allowMutations: false,
13
+ };
14
+
15
+ test("exposes docker_service_ls as a readonly swarm service list", () => {
16
+ const tool = buildReadonlyTools(config).find((t) => t.name === "docker_service_ls");
17
+ expect(tool).toBeDefined();
18
+ expect(tool?.mutation).toBeFalsy();
19
+ expect(tool?.inputSchema.parse({})).toEqual({});
20
+ });
21
+
22
+ const dailyDebugTools = [
23
+ "host_listen",
24
+ "host_failed_units",
25
+ "host_top",
26
+ "host_dmesg",
27
+ "docker_node_ls",
28
+ ] as const;
29
+
30
+ test.each([...dailyDebugTools])("exposes readonly debug tool %s", (name) => {
31
+ const tool = buildReadonlyTools(config).find((t) => t.name === name);
32
+ expect(tool).toBeDefined();
33
+ expect(tool?.mutation).toBeFalsy();
34
+ });
35
+
36
+ test("host_dmesg accepts optional n", () => {
37
+ const tool = buildReadonlyTools(config).find((t) => t.name === "host_dmesg");
38
+ expect(tool?.inputSchema.parse({})).toEqual({});
39
+ expect(tool?.inputSchema.parse({ n: 50 })).toEqual({ n: 50 });
40
+ });
41
+
42
+ test("resolveComposeDir uses argument or config and rejects missing dir", () => {
43
+ expect(resolveComposeDir({ ...config, composeDir: "/opt/stack" })).toBe(
44
+ "/opt/stack"
45
+ );
46
+ expect(resolveComposeDir(config, "/srv/app")).toBe("/srv/app");
47
+ expect(() => resolveComposeDir(config)).toThrow(/compose dir required/);
48
+ });
49
+
50
+ test("vps_journal rejects units outside the allowlist before SSH", async () => {
51
+ const tool = buildReadonlyTools(config).find((t) => t.name === "vps_journal");
52
+ await expect(tool!.handler({ unit: "nginx" })).rejects.toThrow(/not allowed/);
53
+ });
54
+
55
+ test("docker_logs rejects unsafe since values before SSH", async () => {
56
+ const tool = buildReadonlyTools(config).find((t) => t.name === "docker_logs");
57
+ await expect(
58
+ tool!.handler({ name: "web", since: "1h; cat /etc/shadow" })
59
+ ).rejects.toThrow(/Invalid since/);
60
+ });