@mseep/mcp-swarmpit 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/CLAUDE.md +128 -0
- package/README.md +416 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +297 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +41 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/sanitize.d.ts +41 -0
- package/dist/sanitize.js +165 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/test/config.test.d.ts +1 -0
- package/dist/test/config.test.js +103 -0
- package/dist/test/config.test.js.map +1 -0
- package/dist/test/file-ref.test.d.ts +1 -0
- package/dist/test/file-ref.test.js +163 -0
- package/dist/test/file-ref.test.js.map +1 -0
- package/dist/test/helpers.test.d.ts +1 -0
- package/dist/test/helpers.test.js +133 -0
- package/dist/test/helpers.test.js.map +1 -0
- package/dist/test/sanitize.test.d.ts +1 -0
- package/dist/test/sanitize.test.js +207 -0
- package/dist/test/sanitize.test.js.map +1 -0
- package/dist/tools/admin.d.ts +3 -0
- package/dist/tools/admin.js +64 -0
- package/dist/tools/admin.js.map +1 -0
- package/dist/tools/configs.d.ts +4 -0
- package/dist/tools/configs.js +70 -0
- package/dist/tools/configs.js.map +1 -0
- package/dist/tools/dashboard.d.ts +3 -0
- package/dist/tools/dashboard.js +41 -0
- package/dist/tools/dashboard.js.map +1 -0
- package/dist/tools/helpers.d.ts +16 -0
- package/dist/tools/helpers.js +74 -0
- package/dist/tools/helpers.js.map +1 -0
- package/dist/tools/networks.d.ts +3 -0
- package/dist/tools/networks.js +70 -0
- package/dist/tools/networks.js.map +1 -0
- package/dist/tools/nodes.d.ts +3 -0
- package/dist/tools/nodes.js +59 -0
- package/dist/tools/nodes.js.map +1 -0
- package/dist/tools/register.d.ts +3 -0
- package/dist/tools/register.js +30 -0
- package/dist/tools/register.js.map +1 -0
- package/dist/tools/secrets.d.ts +4 -0
- package/dist/tools/secrets.js +70 -0
- package/dist/tools/secrets.js.map +1 -0
- package/dist/tools/services.d.ts +4 -0
- package/dist/tools/services.js +198 -0
- package/dist/tools/services.js.map +1 -0
- package/dist/tools/stacks.d.ts +4 -0
- package/dist/tools/stacks.js +196 -0
- package/dist/tools/stacks.js.map +1 -0
- package/dist/tools/tasks.d.ts +3 -0
- package/dist/tools/tasks.js +23 -0
- package/dist/tools/tasks.js.map +1 -0
- package/dist/tools/timeseries.d.ts +3 -0
- package/dist/tools/timeseries.js +41 -0
- package/dist/tools/timeseries.js.map +1 -0
- package/dist/tools/util.d.ts +3 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/util.js.map +1 -0
- package/dist/tools/volumes.d.ts +3 -0
- package/dist/tools/volumes.js +59 -0
- package/dist/tools/volumes.js.map +1 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/client.ts +391 -0
- package/src/config.ts +57 -0
- package/src/index.ts +49 -0
- package/src/sanitize.ts +218 -0
- package/src/test/config.test.ts +118 -0
- package/src/test/file-ref.test.ts +191 -0
- package/src/test/helpers.test.ts +147 -0
- package/src/test/sanitize.test.ts +234 -0
- package/src/tools/admin.ts +93 -0
- package/src/tools/configs.ts +101 -0
- package/src/tools/dashboard.ts +65 -0
- package/src/tools/helpers.ts +91 -0
- package/src/tools/networks.ts +99 -0
- package/src/tools/nodes.ts +88 -0
- package/src/tools/register.ts +36 -0
- package/src/tools/secrets.ts +101 -0
- package/src/tools/services.ts +283 -0
- package/src/tools/stacks.ts +282 -0
- package/src/tools/tasks.ts +37 -0
- package/src/tools/timeseries.ts +65 -0
- package/src/tools/util.ts +20 -0
- package/src/tools/volumes.ts +88 -0
- package/src/types.ts +131 -0
- package/swagger.json +1 -0
- package/swarmpit-config.example.json +9 -0
- package/tsconfig.json +15 -0
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { SwarmpitService, SwarmpitEnvVar } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SENSITIVE_PATTERNS: RegExp[] = [
|
|
4
|
+
/pass/i,
|
|
5
|
+
/secret/i,
|
|
6
|
+
/token/i,
|
|
7
|
+
/key/i,
|
|
8
|
+
/auth/i,
|
|
9
|
+
/credential/i,
|
|
10
|
+
/private/i,
|
|
11
|
+
/connection.?string/i,
|
|
12
|
+
/dsn/i,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
let extraPatterns: RegExp[] = [];
|
|
16
|
+
|
|
17
|
+
export function setExtraRedactPatterns(patterns: string[]): void {
|
|
18
|
+
extraPatterns = patterns.map((p) => new RegExp(p, "i"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSensitiveEnvVar(name: string): boolean {
|
|
22
|
+
return DEFAULT_SENSITIVE_PATTERNS.some((p) => p.test(name))
|
|
23
|
+
|| extraPatterns.some((p) => p.test(name));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function redactEnvVars(
|
|
27
|
+
variables: SwarmpitEnvVar[] | undefined,
|
|
28
|
+
redactAll: boolean
|
|
29
|
+
): SwarmpitEnvVar[] | undefined {
|
|
30
|
+
if (!variables) return undefined;
|
|
31
|
+
return variables.map((v) => ({
|
|
32
|
+
name: v.name,
|
|
33
|
+
value: redactAll || isSensitiveEnvVar(v.name) ? "[REDACTED]" : v.value,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sanitizeService(
|
|
38
|
+
service: SwarmpitService,
|
|
39
|
+
redactAll = true
|
|
40
|
+
): SwarmpitService {
|
|
41
|
+
return {
|
|
42
|
+
...service,
|
|
43
|
+
variables: redactEnvVars(service.variables, redactAll) ?? [],
|
|
44
|
+
secrets: service.secrets?.map(({ id, secretName, secretTarget }) => ({
|
|
45
|
+
id,
|
|
46
|
+
secretName,
|
|
47
|
+
secretTarget,
|
|
48
|
+
})) ?? [],
|
|
49
|
+
configs: service.configs?.map(({ id, configName, configTarget }) => ({
|
|
50
|
+
id,
|
|
51
|
+
configName,
|
|
52
|
+
configTarget,
|
|
53
|
+
})) ?? [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sanitizeServices(
|
|
58
|
+
services: SwarmpitService[],
|
|
59
|
+
redactAll = true
|
|
60
|
+
): SwarmpitService[] {
|
|
61
|
+
return services.map((s) => sanitizeService(s, redactAll));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sanitize a Docker Compose YAML string by redacting environment variable
|
|
66
|
+
* values that look sensitive. Handles both forms:
|
|
67
|
+
*
|
|
68
|
+
* environment:
|
|
69
|
+
* DATABASE_PASSWORD: supersecret → DATABASE_PASSWORD: [REDACTED]
|
|
70
|
+
* - DATABASE_PASSWORD=supersecret → - DATABASE_PASSWORD=[REDACTED]
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeComposeYaml(yaml: string, redactAll = true): string {
|
|
73
|
+
if (!yaml) return yaml;
|
|
74
|
+
|
|
75
|
+
// Key-value form: SOME_KEY: some_value
|
|
76
|
+
const sanitized = yaml.replace(
|
|
77
|
+
/^(\s+)([A-Z_][A-Z0-9_]*):\s*(.+)$/gm,
|
|
78
|
+
(match, indent: string, key: string, value: string) => {
|
|
79
|
+
// Skip non-env-var-looking keys (lowercase, contains dots, etc.)
|
|
80
|
+
if (key !== key.toUpperCase()) return match;
|
|
81
|
+
// Skip values that look like YAML references, objects, or arrays
|
|
82
|
+
if (value.startsWith("&") || value.startsWith("*") || value.startsWith("{") || value.startsWith("[")) return match;
|
|
83
|
+
if (redactAll || isSensitiveEnvVar(key)) {
|
|
84
|
+
return `${indent}${key}: [REDACTED]`;
|
|
85
|
+
}
|
|
86
|
+
return match;
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// List form: - SOME_KEY=some_value
|
|
91
|
+
return sanitized.replace(
|
|
92
|
+
/^(\s+-\s+)([A-Z_][A-Z0-9_]*)=(.+)$/gm,
|
|
93
|
+
(match, prefix: string, key: string, value: string) => {
|
|
94
|
+
if (redactAll || isSensitiveEnvVar(key)) {
|
|
95
|
+
return `${prefix}${key}=[REDACTED]`;
|
|
96
|
+
}
|
|
97
|
+
return match;
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve $env:VAR_NAME references in a compose YAML string.
|
|
104
|
+
* Plain values pass through unchanged. Only $env: prefixed values
|
|
105
|
+
* are resolved from process.env.
|
|
106
|
+
*
|
|
107
|
+
* DATABASE_PASSWORD: $env:MY_DB_PASS → DATABASE_PASSWORD: actual-value
|
|
108
|
+
* NODE_ENV: production → NODE_ENV: production
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* Resolve $env:VAR_NAME references in a compose YAML string.
|
|
112
|
+
* Plain values pass through unchanged. Only $env: prefixed values
|
|
113
|
+
* are resolved from process.env.
|
|
114
|
+
*
|
|
115
|
+
* DATABASE_PASSWORD: $env:MY_DB_PASS → DATABASE_PASSWORD: actual-value
|
|
116
|
+
* NODE_ENV: production → NODE_ENV: production
|
|
117
|
+
*/
|
|
118
|
+
export function resolveComposeEnvRefs(yaml: string): string {
|
|
119
|
+
if (!yaml) return yaml;
|
|
120
|
+
|
|
121
|
+
// Key-value form: KEY: $env:VAR_NAME
|
|
122
|
+
const resolved = yaml.replace(
|
|
123
|
+
/^(\s+[A-Z_][A-Z0-9_]*:\s*)\$env:([A-Z_][A-Z0-9_]*)$/gm,
|
|
124
|
+
(match, prefix: string, varName: string) => {
|
|
125
|
+
const value = process.env[varName];
|
|
126
|
+
if (!value) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`$env:${varName} referenced in compose but not set in environment`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return `${prefix}${value}`;
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// List form: - KEY=$env:VAR_NAME
|
|
136
|
+
return resolved.replace(
|
|
137
|
+
/^(\s+-\s+[A-Z_][A-Z0-9_]*=)\$env:([A-Z_][A-Z0-9_]*)$/gm,
|
|
138
|
+
(match, prefix: string, varName: string) => {
|
|
139
|
+
const value = process.env[varName];
|
|
140
|
+
if (!value) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`$env:${varName} referenced in compose but not set in environment`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return `${prefix}${value}`;
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build env var lookup from raw compose YAML. Extracts KEY=value pairs
|
|
152
|
+
* so [REDACTED] values can be restored from the original.
|
|
153
|
+
*/
|
|
154
|
+
function extractComposeEnvVars(yaml: string): Map<string, string> {
|
|
155
|
+
const vars = new Map<string, string>();
|
|
156
|
+
if (!yaml) return vars;
|
|
157
|
+
|
|
158
|
+
// Key-value form: KEY: value
|
|
159
|
+
for (const match of yaml.matchAll(
|
|
160
|
+
/^\s+([A-Z_][A-Z0-9_]*):\s*(.+)$/gm
|
|
161
|
+
)) {
|
|
162
|
+
vars.set(match[1], match[2]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// List form: - KEY=value
|
|
166
|
+
for (const match of yaml.matchAll(
|
|
167
|
+
/^\s+-\s+([A-Z_][A-Z0-9_]*)=(.+)$/gm
|
|
168
|
+
)) {
|
|
169
|
+
vars.set(match[1], match[2]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return vars;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Restore [REDACTED] values in an edited compose YAML from the original
|
|
177
|
+
* raw compose. This allows safe round-tripping:
|
|
178
|
+
*
|
|
179
|
+
* 1. swarmpit_get_stack returns compose with sensitive values as [REDACTED]
|
|
180
|
+
* 2. Claude edits only what it needs, leaves [REDACTED] in place
|
|
181
|
+
* 3. This function restores original values where [REDACTED] remains
|
|
182
|
+
* 4. $env:VAR references are resolved from process.env
|
|
183
|
+
* 5. Plaintext values pass through as-is
|
|
184
|
+
*/
|
|
185
|
+
export function restoreRedactedValues(
|
|
186
|
+
editedYaml: string,
|
|
187
|
+
originalYaml: string
|
|
188
|
+
): string {
|
|
189
|
+
if (!editedYaml) return editedYaml;
|
|
190
|
+
|
|
191
|
+
const origVars = extractComposeEnvVars(originalYaml);
|
|
192
|
+
|
|
193
|
+
// Key-value form: restore KEY: [REDACTED]
|
|
194
|
+
let restored = editedYaml.replace(
|
|
195
|
+
/^(\s+)([A-Z_][A-Z0-9_]*):\s*\[REDACTED\]$/gm,
|
|
196
|
+
(match, indent: string, key: string) => {
|
|
197
|
+
const original = origVars.get(key);
|
|
198
|
+
if (original) {
|
|
199
|
+
return `${indent}${key}: ${original}`;
|
|
200
|
+
}
|
|
201
|
+
return match;
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// List form: restore - KEY=[REDACTED]
|
|
206
|
+
restored = restored.replace(
|
|
207
|
+
/^(\s+-\s+)([A-Z_][A-Z0-9_]*)=\[REDACTED\]$/gm,
|
|
208
|
+
(match, prefix: string, key: string) => {
|
|
209
|
+
const original = origVars.get(key);
|
|
210
|
+
if (original) {
|
|
211
|
+
return `${prefix}${key}=${original}`;
|
|
212
|
+
}
|
|
213
|
+
return match;
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return restored;
|
|
218
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
|
|
5
|
+
describe("loadConfig", () => {
|
|
6
|
+
const saved = { ...process.env };
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Clear all SWARMPIT_ vars
|
|
10
|
+
for (const key of Object.keys(process.env)) {
|
|
11
|
+
if (key.startsWith("SWARMPIT_")) delete process.env[key];
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
// Restore original env
|
|
17
|
+
for (const key of Object.keys(process.env)) {
|
|
18
|
+
if (key.startsWith("SWARMPIT_")) delete process.env[key];
|
|
19
|
+
}
|
|
20
|
+
Object.assign(process.env, saved);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("loads config from env vars", () => {
|
|
24
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
25
|
+
process.env.SWARMPIT_TOKEN = "test-token";
|
|
26
|
+
|
|
27
|
+
const config = loadConfig();
|
|
28
|
+
assert.equal(config.url, "https://swarmpit.example.com");
|
|
29
|
+
assert.equal(config.token, "test-token");
|
|
30
|
+
assert.equal(config.redact, "all");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("strips trailing slashes from URL", () => {
|
|
34
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com///";
|
|
35
|
+
process.env.SWARMPIT_TOKEN = "test-token";
|
|
36
|
+
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
assert.equal(config.url, "https://swarmpit.example.com");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("reads SWARMPIT_REDACT mode", () => {
|
|
42
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
43
|
+
process.env.SWARMPIT_TOKEN = "test-token";
|
|
44
|
+
process.env.SWARMPIT_REDACT = "sensitive";
|
|
45
|
+
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
assert.equal(config.redact, "sensitive");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts none redact mode", () => {
|
|
51
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
52
|
+
process.env.SWARMPIT_TOKEN = "test-token";
|
|
53
|
+
process.env.SWARMPIT_REDACT = "none";
|
|
54
|
+
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
assert.equal(config.redact, "none");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws on missing SWARMPIT_URL", () => {
|
|
60
|
+
process.env.SWARMPIT_TOKEN = "test-token";
|
|
61
|
+
assert.throws(() => loadConfig(), /SWARMPIT_URL is not set/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws on missing SWARMPIT_TOKEN", () => {
|
|
65
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
66
|
+
assert.throws(
|
|
67
|
+
() => loadConfig(),
|
|
68
|
+
/Neither SWARMPIT_TOKEN nor SWARMPIT_TOKEN_FILE is set/
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("loads token from SWARMPIT_TOKEN_FILE", async () => {
|
|
73
|
+
const { writeFileSync, unlinkSync, mkdtempSync, rmSync } = await import("node:fs");
|
|
74
|
+
const { tmpdir } = await import("node:os");
|
|
75
|
+
const { join } = await import("node:path");
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), "mcp-token-"));
|
|
77
|
+
const path = join(dir, "token");
|
|
78
|
+
writeFileSync(path, "file-loaded-token\n"); // trailing newline should be stripped
|
|
79
|
+
try {
|
|
80
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
81
|
+
process.env.SWARMPIT_TOKEN_FILE = path;
|
|
82
|
+
const config = loadConfig();
|
|
83
|
+
assert.equal(config.token, "file-loaded-token");
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("SWARMPIT_TOKEN_FILE takes precedence over SWARMPIT_TOKEN", async () => {
|
|
90
|
+
const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs");
|
|
91
|
+
const { tmpdir } = await import("node:os");
|
|
92
|
+
const { join } = await import("node:path");
|
|
93
|
+
const dir = mkdtempSync(join(tmpdir(), "mcp-token-"));
|
|
94
|
+
const path = join(dir, "token");
|
|
95
|
+
writeFileSync(path, "from-file");
|
|
96
|
+
try {
|
|
97
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
98
|
+
process.env.SWARMPIT_TOKEN = "from-env";
|
|
99
|
+
process.env.SWARMPIT_TOKEN_FILE = path;
|
|
100
|
+
assert.equal(loadConfig().token, "from-file");
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("throws when SWARMPIT_TOKEN_FILE points to missing file", () => {
|
|
107
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
108
|
+
process.env.SWARMPIT_TOKEN_FILE = "/nonexistent/token-file";
|
|
109
|
+
assert.throws(() => loadConfig(), /could not be read/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("throws on invalid SWARMPIT_REDACT", () => {
|
|
113
|
+
process.env.SWARMPIT_URL = "https://swarmpit.example.com";
|
|
114
|
+
process.env.SWARMPIT_TOKEN = "test-token";
|
|
115
|
+
process.env.SWARMPIT_REDACT = "invalid";
|
|
116
|
+
assert.throws(() => loadConfig(), /SWARMPIT_REDACT/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFileSync, unlinkSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { registerConfigTools } from "../tools/configs.js";
|
|
7
|
+
import { registerSecretTools } from "../tools/secrets.js";
|
|
8
|
+
import { registerStackTools } from "../tools/stacks.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal mock that captures tool handlers registered on the MCP server,
|
|
12
|
+
* so we can invoke them directly in tests without the transport layer.
|
|
13
|
+
*/
|
|
14
|
+
function mockServer(): { handlers: Record<string, Function>; tool: Function } {
|
|
15
|
+
const handlers: Record<string, Function> = {};
|
|
16
|
+
return {
|
|
17
|
+
handlers,
|
|
18
|
+
tool(name: string, _desc: string, _schema: unknown, handler: Function) {
|
|
19
|
+
handlers[name] = handler;
|
|
20
|
+
},
|
|
21
|
+
} as unknown as { handlers: Record<string, Function>; tool: Function };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mockClient() {
|
|
25
|
+
const calls: Record<string, unknown[][]> = {};
|
|
26
|
+
const record = (method: string) => (...args: unknown[]) => {
|
|
27
|
+
(calls[method] ||= []).push(args);
|
|
28
|
+
return Promise.resolve(undefined as unknown);
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
calls,
|
|
32
|
+
listSecrets: record("listSecrets"),
|
|
33
|
+
listConfigs: record("listConfigs"),
|
|
34
|
+
createSecret: record("createSecret"),
|
|
35
|
+
createConfig: record("createConfig"),
|
|
36
|
+
createStack: record("createStack"),
|
|
37
|
+
updateStack: record("updateStack"),
|
|
38
|
+
getStackFile: () => Promise.resolve({ compose: "" }),
|
|
39
|
+
createStackFile: record("createStackFile"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("$file integration", () => {
|
|
44
|
+
let tmpDir: string;
|
|
45
|
+
let filePath: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mcp-swarmpit-test-"));
|
|
49
|
+
filePath = join(tmpDir, "data.txt");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("create_config reads data from $file", async () => {
|
|
57
|
+
writeFileSync(filePath, "<html>hello from file</html>");
|
|
58
|
+
const server = mockServer();
|
|
59
|
+
const client = mockClient();
|
|
60
|
+
registerConfigTools(server as never, client as never, "sensitive");
|
|
61
|
+
|
|
62
|
+
const result = await server.handlers.create_config({
|
|
63
|
+
configName: "my_config",
|
|
64
|
+
data: { $file: filePath },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.equal(result.isError, undefined);
|
|
68
|
+
const [arg] = client.calls.createConfig[0] as [{ configName: string; data: string }];
|
|
69
|
+
assert.equal(arg.configName, "my_config");
|
|
70
|
+
assert.equal(arg.data, "<html>hello from file</html>");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("create_secret reads data from $file", async () => {
|
|
74
|
+
writeFileSync(filePath, "super-secret-token");
|
|
75
|
+
const server = mockServer();
|
|
76
|
+
const client = mockClient();
|
|
77
|
+
registerSecretTools(server as never, client as never, "sensitive");
|
|
78
|
+
|
|
79
|
+
const result = await server.handlers.create_secret({
|
|
80
|
+
secretName: "api_token",
|
|
81
|
+
data: { $file: filePath },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.equal(result.isError, undefined);
|
|
85
|
+
const [arg] = client.calls.createSecret[0] as [{ secretName: string; data: string }];
|
|
86
|
+
assert.equal(arg.secretName, "api_token");
|
|
87
|
+
assert.equal(arg.data, "super-secret-token");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("create_stack reads compose from $file", async () => {
|
|
91
|
+
const compose = "version: '3.3'\nservices:\n web:\n image: nginx\n";
|
|
92
|
+
writeFileSync(filePath, compose);
|
|
93
|
+
const server = mockServer();
|
|
94
|
+
const client = mockClient();
|
|
95
|
+
registerStackTools(server as never, client as never, "sensitive");
|
|
96
|
+
|
|
97
|
+
const result = await server.handlers.create_stack({
|
|
98
|
+
name: "myapp",
|
|
99
|
+
compose: { $file: filePath },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(result.isError, undefined);
|
|
103
|
+
const [arg] = client.calls.createStack[0] as [{ name: string; spec: { compose: string } }];
|
|
104
|
+
assert.equal(arg.name, "myapp");
|
|
105
|
+
assert.equal(arg.spec.compose, compose);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("update_stack reads compose from $file", async () => {
|
|
109
|
+
const compose = "version: '3.3'\nservices:\n web:\n image: nginx:updated\n";
|
|
110
|
+
writeFileSync(filePath, compose);
|
|
111
|
+
const server = mockServer();
|
|
112
|
+
const client = mockClient();
|
|
113
|
+
registerStackTools(server as never, client as never, "sensitive");
|
|
114
|
+
|
|
115
|
+
const result = await server.handlers.update_stack({
|
|
116
|
+
name: "myapp",
|
|
117
|
+
compose: { $file: filePath },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.equal(result.isError, undefined);
|
|
121
|
+
const [name, yaml] = client.calls.updateStack[0] as [string, string];
|
|
122
|
+
assert.equal(name, "myapp");
|
|
123
|
+
assert.equal(yaml, compose);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("create_stack_file reads compose from $file", async () => {
|
|
127
|
+
const compose = "version: '3.3'\nservices: {}\n";
|
|
128
|
+
writeFileSync(filePath, compose);
|
|
129
|
+
const server = mockServer();
|
|
130
|
+
const client = mockClient();
|
|
131
|
+
registerStackTools(server as never, client as never, "sensitive");
|
|
132
|
+
|
|
133
|
+
const result = await server.handlers.create_stack_file({
|
|
134
|
+
name: "myapp",
|
|
135
|
+
compose: { $file: filePath },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.equal(result.isError, undefined);
|
|
139
|
+
const [name, yaml] = client.calls.createStackFile[0] as [string, string];
|
|
140
|
+
assert.equal(name, "myapp");
|
|
141
|
+
assert.equal(yaml, compose);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("create_config with $file does not resolve $env: inside raw config data", async () => {
|
|
145
|
+
writeFileSync(filePath, "$env:SHOULD_NOT_RESOLVE");
|
|
146
|
+
const server = mockServer();
|
|
147
|
+
const client = mockClient();
|
|
148
|
+
registerConfigTools(server as never, client as never, "sensitive");
|
|
149
|
+
|
|
150
|
+
await server.handlers.create_config({
|
|
151
|
+
configName: "x",
|
|
152
|
+
data: { $file: filePath },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const [arg] = client.calls.createConfig[0] as [{ data: string }];
|
|
156
|
+
assert.equal(arg.data, "$env:SHOULD_NOT_RESOLVE");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("create_stack with $file resolves $env: refs inside compose", async () => {
|
|
160
|
+
process.env.TEST_STACK_FILE_VAR = "resolved-value";
|
|
161
|
+
const compose = "services:\n app:\n environment:\n SECRET: $env:TEST_STACK_FILE_VAR\n";
|
|
162
|
+
writeFileSync(filePath, compose);
|
|
163
|
+
const server = mockServer();
|
|
164
|
+
const client = mockClient();
|
|
165
|
+
registerStackTools(server as never, client as never, "sensitive");
|
|
166
|
+
|
|
167
|
+
const result = await server.handlers.create_stack({
|
|
168
|
+
name: "myapp",
|
|
169
|
+
compose: { $file: filePath },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.equal(result.isError, undefined);
|
|
173
|
+
const [arg] = client.calls.createStack[0] as [{ spec: { compose: string } }];
|
|
174
|
+
assert.ok(arg.spec.compose.includes("SECRET: resolved-value"));
|
|
175
|
+
delete process.env.TEST_STACK_FILE_VAR;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns isError for missing file", async () => {
|
|
179
|
+
const server = mockServer();
|
|
180
|
+
const client = mockClient();
|
|
181
|
+
registerConfigTools(server as never, client as never, "sensitive");
|
|
182
|
+
|
|
183
|
+
const result = await server.handlers.create_config({
|
|
184
|
+
configName: "x",
|
|
185
|
+
data: { $file: "/nonexistent/path/xyz" },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.equal(result.isError, true);
|
|
189
|
+
assert.equal(client.calls.createConfig, undefined);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { toolResult, toolError, resolveEnvRef, prepareServiceForUpdate, resolveData } from "../tools/helpers.js";
|
|
4
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
describe("toolResult", () => {
|
|
7
|
+
it("wraps data as JSON text content", () => {
|
|
8
|
+
const result = toolResult({ foo: "bar" });
|
|
9
|
+
assert.equal(result.content.length, 1);
|
|
10
|
+
assert.equal(result.content[0].type, "text");
|
|
11
|
+
assert.deepEqual(JSON.parse((result.content[0] as { text: string }).text), { foo: "bar" });
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("toolError", () => {
|
|
16
|
+
it("wraps Error as text with isError flag", () => {
|
|
17
|
+
const result = toolError(new Error("something broke"));
|
|
18
|
+
assert.equal(result.isError, true);
|
|
19
|
+
assert.equal((result.content[0] as { text: string }).text, "something broke");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("wraps string as text with isError flag", () => {
|
|
23
|
+
const result = toolError("plain string error");
|
|
24
|
+
assert.equal(result.isError, true);
|
|
25
|
+
assert.equal((result.content[0] as { text: string }).text, "plain string error");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("resolveEnvRef", () => {
|
|
30
|
+
it("returns plain strings as-is", () => {
|
|
31
|
+
assert.equal(resolveEnvRef("hello"), "hello");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("resolves { $env: VAR } from process.env", () => {
|
|
35
|
+
process.env.TEST_RESOLVE_VAR = "resolved-value";
|
|
36
|
+
assert.equal(resolveEnvRef({ $env: "TEST_RESOLVE_VAR" }), "resolved-value");
|
|
37
|
+
delete process.env.TEST_RESOLVE_VAR;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throws on missing env var ref", () => {
|
|
41
|
+
delete process.env.NONEXISTENT_REF;
|
|
42
|
+
assert.throws(() => resolveEnvRef({ $env: "NONEXISTENT_REF" }), /NONEXISTENT_REF/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws on invalid value type", () => {
|
|
46
|
+
assert.throws(() => resolveEnvRef(42), /Invalid env var value/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("prepareServiceForUpdate", () => {
|
|
51
|
+
it("strips null values", () => {
|
|
52
|
+
const result = prepareServiceForUpdate({
|
|
53
|
+
serviceName: "test",
|
|
54
|
+
mode: "replicated",
|
|
55
|
+
tty: null,
|
|
56
|
+
dir: null,
|
|
57
|
+
command: null,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(result.serviceName, "test");
|
|
60
|
+
assert.equal("tty" in result, false);
|
|
61
|
+
assert.equal("dir" in result, false);
|
|
62
|
+
assert.equal("command" in result, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("strips read-only fields", () => {
|
|
66
|
+
const result = prepareServiceForUpdate({
|
|
67
|
+
id: "abc123",
|
|
68
|
+
createdAt: "2024-01-01",
|
|
69
|
+
updatedAt: "2024-01-02",
|
|
70
|
+
state: "running",
|
|
71
|
+
status: { tasks: { running: 1, total: 1 } },
|
|
72
|
+
serviceName: "test",
|
|
73
|
+
mode: "replicated",
|
|
74
|
+
});
|
|
75
|
+
assert.equal("id" in result, false);
|
|
76
|
+
assert.equal("createdAt" in result, false);
|
|
77
|
+
assert.equal("updatedAt" in result, false);
|
|
78
|
+
assert.equal("state" in result, false);
|
|
79
|
+
assert.equal("status" in result, false);
|
|
80
|
+
assert.equal(result.serviceName, "test");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("trims repository to name and tag only", () => {
|
|
84
|
+
const result = prepareServiceForUpdate({
|
|
85
|
+
serviceName: "test",
|
|
86
|
+
mode: "replicated",
|
|
87
|
+
repository: {
|
|
88
|
+
name: "nginx",
|
|
89
|
+
tag: "alpine",
|
|
90
|
+
image: "nginx:alpine",
|
|
91
|
+
imageDigest: "sha256:abc123",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const repo = result.repository as Record<string, unknown>;
|
|
95
|
+
assert.equal(repo.name, "nginx");
|
|
96
|
+
assert.equal(repo.tag, "alpine");
|
|
97
|
+
assert.equal("image" in repo, false);
|
|
98
|
+
assert.equal("imageDigest" in repo, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("handles repository with empty imageDigest", () => {
|
|
102
|
+
const result = prepareServiceForUpdate({
|
|
103
|
+
serviceName: "test",
|
|
104
|
+
mode: "replicated",
|
|
105
|
+
repository: {
|
|
106
|
+
name: "nginx",
|
|
107
|
+
tag: "alpine",
|
|
108
|
+
image: "nginx:alpine",
|
|
109
|
+
imageDigest: "",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const repo = result.repository as Record<string, unknown>;
|
|
113
|
+
assert.equal(repo.name, "nginx");
|
|
114
|
+
assert.equal(repo.tag, "alpine");
|
|
115
|
+
assert.equal("imageDigest" in repo, false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("resolveData", () => {
|
|
120
|
+
it("returns plain strings as-is", () => {
|
|
121
|
+
assert.equal(resolveData("hello"), "hello");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("reads from file path via $file", () => {
|
|
125
|
+
const path = "/tmp/mcp-swarmpit-test-data.txt";
|
|
126
|
+
writeFileSync(path, "contents from file");
|
|
127
|
+
try {
|
|
128
|
+
assert.equal(resolveData({ $file: path }), "contents from file");
|
|
129
|
+
} finally {
|
|
130
|
+
unlinkSync(path);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("resolves $env references", () => {
|
|
135
|
+
process.env.TEST_DATA_VAR = "env-value";
|
|
136
|
+
assert.equal(resolveData({ $env: "TEST_DATA_VAR" }), "env-value");
|
|
137
|
+
delete process.env.TEST_DATA_VAR;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("throws on missing file", () => {
|
|
141
|
+
assert.throws(() => resolveData({ $file: "/nonexistent/path/xyz" }), /could not be read/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("throws on invalid value type", () => {
|
|
145
|
+
assert.throws(() => resolveData(42), /Invalid data/);
|
|
146
|
+
});
|
|
147
|
+
});
|