@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.
Files changed (98) hide show
  1. package/CLAUDE.md +128 -0
  2. package/README.md +416 -0
  3. package/dist/client.d.ts +107 -0
  4. package/dist/client.js +297 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/config.d.ts +8 -0
  7. package/dist/config.js +41 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +41 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/sanitize.d.ts +41 -0
  13. package/dist/sanitize.js +165 -0
  14. package/dist/sanitize.js.map +1 -0
  15. package/dist/test/config.test.d.ts +1 -0
  16. package/dist/test/config.test.js +103 -0
  17. package/dist/test/config.test.js.map +1 -0
  18. package/dist/test/file-ref.test.d.ts +1 -0
  19. package/dist/test/file-ref.test.js +163 -0
  20. package/dist/test/file-ref.test.js.map +1 -0
  21. package/dist/test/helpers.test.d.ts +1 -0
  22. package/dist/test/helpers.test.js +133 -0
  23. package/dist/test/helpers.test.js.map +1 -0
  24. package/dist/test/sanitize.test.d.ts +1 -0
  25. package/dist/test/sanitize.test.js +207 -0
  26. package/dist/test/sanitize.test.js.map +1 -0
  27. package/dist/tools/admin.d.ts +3 -0
  28. package/dist/tools/admin.js +64 -0
  29. package/dist/tools/admin.js.map +1 -0
  30. package/dist/tools/configs.d.ts +4 -0
  31. package/dist/tools/configs.js +70 -0
  32. package/dist/tools/configs.js.map +1 -0
  33. package/dist/tools/dashboard.d.ts +3 -0
  34. package/dist/tools/dashboard.js +41 -0
  35. package/dist/tools/dashboard.js.map +1 -0
  36. package/dist/tools/helpers.d.ts +16 -0
  37. package/dist/tools/helpers.js +74 -0
  38. package/dist/tools/helpers.js.map +1 -0
  39. package/dist/tools/networks.d.ts +3 -0
  40. package/dist/tools/networks.js +70 -0
  41. package/dist/tools/networks.js.map +1 -0
  42. package/dist/tools/nodes.d.ts +3 -0
  43. package/dist/tools/nodes.js +59 -0
  44. package/dist/tools/nodes.js.map +1 -0
  45. package/dist/tools/register.d.ts +3 -0
  46. package/dist/tools/register.js +30 -0
  47. package/dist/tools/register.js.map +1 -0
  48. package/dist/tools/secrets.d.ts +4 -0
  49. package/dist/tools/secrets.js +70 -0
  50. package/dist/tools/secrets.js.map +1 -0
  51. package/dist/tools/services.d.ts +4 -0
  52. package/dist/tools/services.js +198 -0
  53. package/dist/tools/services.js.map +1 -0
  54. package/dist/tools/stacks.d.ts +4 -0
  55. package/dist/tools/stacks.js +196 -0
  56. package/dist/tools/stacks.js.map +1 -0
  57. package/dist/tools/tasks.d.ts +3 -0
  58. package/dist/tools/tasks.js +23 -0
  59. package/dist/tools/tasks.js.map +1 -0
  60. package/dist/tools/timeseries.d.ts +3 -0
  61. package/dist/tools/timeseries.js +41 -0
  62. package/dist/tools/timeseries.js.map +1 -0
  63. package/dist/tools/util.d.ts +3 -0
  64. package/dist/tools/util.js +10 -0
  65. package/dist/tools/util.js.map +1 -0
  66. package/dist/tools/volumes.d.ts +3 -0
  67. package/dist/tools/volumes.js +59 -0
  68. package/dist/tools/volumes.js.map +1 -0
  69. package/dist/types.d.ts +119 -0
  70. package/dist/types.js +2 -0
  71. package/dist/types.js.map +1 -0
  72. package/package.json +43 -0
  73. package/src/client.ts +391 -0
  74. package/src/config.ts +57 -0
  75. package/src/index.ts +49 -0
  76. package/src/sanitize.ts +218 -0
  77. package/src/test/config.test.ts +118 -0
  78. package/src/test/file-ref.test.ts +191 -0
  79. package/src/test/helpers.test.ts +147 -0
  80. package/src/test/sanitize.test.ts +234 -0
  81. package/src/tools/admin.ts +93 -0
  82. package/src/tools/configs.ts +101 -0
  83. package/src/tools/dashboard.ts +65 -0
  84. package/src/tools/helpers.ts +91 -0
  85. package/src/tools/networks.ts +99 -0
  86. package/src/tools/nodes.ts +88 -0
  87. package/src/tools/register.ts +36 -0
  88. package/src/tools/secrets.ts +101 -0
  89. package/src/tools/services.ts +283 -0
  90. package/src/tools/stacks.ts +282 -0
  91. package/src/tools/tasks.ts +37 -0
  92. package/src/tools/timeseries.ts +65 -0
  93. package/src/tools/util.ts +20 -0
  94. package/src/tools/volumes.ts +88 -0
  95. package/src/types.ts +131 -0
  96. package/swagger.json +1 -0
  97. package/swarmpit-config.example.json +9 -0
  98. package/tsconfig.json +15 -0
@@ -0,0 +1,234 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ sanitizeService,
5
+ sanitizeServices,
6
+ sanitizeComposeYaml,
7
+ resolveComposeEnvRefs,
8
+ restoreRedactedValues,
9
+ setExtraRedactPatterns,
10
+ } from "../sanitize.js";
11
+ import type { SwarmpitService } from "../types.js";
12
+
13
+ function makeService(overrides: Partial<SwarmpitService> = {}): SwarmpitService {
14
+ return {
15
+ id: "test-id",
16
+ version: 1,
17
+ createdAt: "2024-01-01T00:00:00Z",
18
+ updatedAt: "2024-01-01T00:00:00Z",
19
+ repository: { name: "test", tag: "latest", image: "test:latest", imageDigest: "sha256:abc" },
20
+ serviceName: "test_service",
21
+ mode: "replicated",
22
+ replicas: 1,
23
+ state: "running",
24
+ status: { tasks: { running: 1, total: 1 }, update: "", message: "" },
25
+ ports: [],
26
+ mounts: [],
27
+ networks: [],
28
+ secrets: [],
29
+ configs: [],
30
+ variables: [],
31
+ labels: [],
32
+ command: null,
33
+ stack: "test",
34
+ resources: { reservation: { cpu: 0, memory: 0 }, limit: { cpu: 0, memory: 0 } },
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("sanitizeService", () => {
40
+ it("redacts all env vars when redactAll=true", () => {
41
+ const service = makeService({
42
+ variables: [
43
+ { name: "NODE_ENV", value: "production" },
44
+ { name: "DB_PASS", value: "secret123" },
45
+ ],
46
+ });
47
+ const result = sanitizeService(service, true);
48
+ assert.equal(result.variables[0].value, "[REDACTED]");
49
+ assert.equal(result.variables[1].value, "[REDACTED]");
50
+ });
51
+
52
+ it("only redacts sensitive vars when redactAll=false", () => {
53
+ const service = makeService({
54
+ variables: [
55
+ { name: "NODE_ENV", value: "production" },
56
+ { name: "DB_PASS", value: "secret123" },
57
+ { name: "API_KEY", value: "key123" },
58
+ { name: "LOG_LEVEL", value: "debug" },
59
+ { name: "SECRET_VALUE", value: "hidden" },
60
+ { name: "AUTH_HEADER", value: "bearer xyz" },
61
+ ],
62
+ });
63
+ const result = sanitizeService(service, false);
64
+ assert.equal(result.variables[0].value, "production"); // NODE_ENV — not sensitive
65
+ assert.equal(result.variables[1].value, "[REDACTED]"); // DB_PASS — matches "pass"
66
+ assert.equal(result.variables[2].value, "[REDACTED]"); // API_KEY — matches "key"
67
+ assert.equal(result.variables[3].value, "debug"); // LOG_LEVEL — not sensitive
68
+ assert.equal(result.variables[4].value, "[REDACTED]"); // SECRET_VALUE — matches "secret"
69
+ assert.equal(result.variables[5].value, "[REDACTED]"); // AUTH_HEADER — matches "auth"
70
+ });
71
+
72
+ it("redacts extra custom patterns", () => {
73
+ setExtraRedactPatterns(["GRAFANA", "RPC"]);
74
+ const service = makeService({
75
+ variables: [
76
+ { name: "GRAFANA", value: "https://grafana.example.com" },
77
+ { name: "RPC_URL", value: "ws://node:9999" },
78
+ { name: "LOG_LEVEL", value: "debug" },
79
+ ],
80
+ });
81
+ const result = sanitizeService(service, false);
82
+ assert.equal(result.variables[0].value, "[REDACTED]"); // GRAFANA — custom pattern
83
+ assert.equal(result.variables[1].value, "[REDACTED]"); // RPC_URL — custom pattern
84
+ assert.equal(result.variables[2].value, "debug"); // LOG_LEVEL — no match
85
+ setExtraRedactPatterns([]); // clean up
86
+ });
87
+
88
+ it("preserves env var names", () => {
89
+ const service = makeService({
90
+ variables: [{ name: "PASSWORD", value: "secret" }],
91
+ });
92
+ const result = sanitizeService(service, true);
93
+ assert.equal(result.variables[0].name, "PASSWORD");
94
+ });
95
+
96
+ it("does not modify command args", () => {
97
+ const service = makeService({
98
+ command: ["--db", "postgres://user:secret@host:5432/db"],
99
+ });
100
+ const result = sanitizeService(service, true);
101
+ assert.deepEqual(result.command, ["--db", "postgres://user:secret@host:5432/db"]);
102
+ });
103
+
104
+ it("strips secret data fields but keeps references", () => {
105
+ const service = makeService({
106
+ secrets: [{ id: "s1", secretName: "db_pass", secretTarget: "/run/secrets/db_pass" }],
107
+ configs: [{ id: "c1", configName: "app_config", configTarget: "/etc/app.conf" }],
108
+ });
109
+ const result = sanitizeService(service);
110
+ assert.deepEqual(result.secrets, [{ id: "s1", secretName: "db_pass", secretTarget: "/run/secrets/db_pass" }]);
111
+ assert.deepEqual(result.configs, [{ id: "c1", configName: "app_config", configTarget: "/etc/app.conf" }]);
112
+ });
113
+ });
114
+
115
+ describe("sanitizeServices", () => {
116
+ it("sanitizes an array of services", () => {
117
+ const services = [
118
+ makeService({ variables: [{ name: "PASSWORD", value: "s1" }] }),
119
+ makeService({ variables: [{ name: "TOKEN", value: "s2" }] }),
120
+ ];
121
+ const result = sanitizeServices(services, false);
122
+ assert.equal(result[0].variables[0].value, "[REDACTED]");
123
+ assert.equal(result[1].variables[0].value, "[REDACTED]");
124
+ });
125
+ });
126
+
127
+ describe("sanitizeComposeYaml", () => {
128
+ it("redacts all env vars in key-value form when redactAll=true", () => {
129
+ const yaml = `services:
130
+ app:
131
+ environment:
132
+ NODE_ENV: production
133
+ DB_HOST: localhost`;
134
+ const result = sanitizeComposeYaml(yaml, true);
135
+ assert.ok(result.includes("NODE_ENV: [REDACTED]"));
136
+ assert.ok(result.includes("DB_HOST: [REDACTED]"));
137
+ });
138
+
139
+ it("only redacts sensitive vars when redactAll=false", () => {
140
+ const yaml = `services:
141
+ app:
142
+ environment:
143
+ NODE_ENV: production
144
+ DB_PASSWORD: secret123`;
145
+ const result = sanitizeComposeYaml(yaml, false);
146
+ assert.ok(result.includes("NODE_ENV: production"));
147
+ assert.ok(result.includes("DB_PASSWORD: [REDACTED]"));
148
+ });
149
+
150
+ it("redacts list form env vars", () => {
151
+ const yaml = `services:
152
+ app:
153
+ environment:
154
+ - NODE_ENV=production
155
+ - DB_PASSWORD=secret123`;
156
+ const result = sanitizeComposeYaml(yaml, false);
157
+ assert.ok(result.includes("- NODE_ENV=production"));
158
+ assert.ok(result.includes("- DB_PASSWORD=[REDACTED]"));
159
+ });
160
+
161
+ it("does not redact YAML references or objects", () => {
162
+ const yaml = `services:
163
+ app:
164
+ environment:
165
+ CONFIG: &default_config
166
+ REF: *default_config`;
167
+ const result = sanitizeComposeYaml(yaml, true);
168
+ assert.ok(result.includes("CONFIG: &default_config"));
169
+ assert.ok(result.includes("REF: *default_config"));
170
+ });
171
+
172
+ it("does not modify commands in compose", () => {
173
+ const yaml = `services:
174
+ app:
175
+ command: ["--db", "postgres://user:secret@host/db"]`;
176
+ const result = sanitizeComposeYaml(yaml, true);
177
+ assert.ok(result.includes("postgres://user:secret@host/db"));
178
+ });
179
+ });
180
+
181
+ describe("resolveComposeEnvRefs", () => {
182
+ it("resolves $env:VAR_NAME in key-value form", () => {
183
+ process.env.TEST_SECRET = "resolved-value";
184
+ const yaml = ` DB_PASSWORD: $env:TEST_SECRET`;
185
+ const result = resolveComposeEnvRefs(yaml);
186
+ assert.ok(result.includes("DB_PASSWORD: resolved-value"));
187
+ delete process.env.TEST_SECRET;
188
+ });
189
+
190
+ it("resolves $env:VAR_NAME in list form", () => {
191
+ process.env.TEST_SECRET = "resolved-value";
192
+ const yaml = ` - DB_PASSWORD=$env:TEST_SECRET`;
193
+ const result = resolveComposeEnvRefs(yaml);
194
+ assert.ok(result.includes("- DB_PASSWORD=resolved-value"));
195
+ delete process.env.TEST_SECRET;
196
+ });
197
+
198
+ it("passes through plain values unchanged", () => {
199
+ const yaml = ` NODE_ENV: production`;
200
+ const result = resolveComposeEnvRefs(yaml);
201
+ assert.equal(result, yaml);
202
+ });
203
+
204
+ it("throws on missing env var", () => {
205
+ delete process.env.NONEXISTENT_VAR;
206
+ const yaml = ` DB_PASSWORD: $env:NONEXISTENT_VAR`;
207
+ assert.throws(() => resolveComposeEnvRefs(yaml), /NONEXISTENT_VAR/);
208
+ });
209
+ });
210
+
211
+ describe("restoreRedactedValues", () => {
212
+ it("restores [REDACTED] from original in key-value form", () => {
213
+ const edited = ` DB_PASSWORD: [REDACTED]\n NODE_ENV: staging`;
214
+ const original = ` DB_PASSWORD: secret123\n NODE_ENV: production`;
215
+ const result = restoreRedactedValues(edited, original);
216
+ assert.ok(result.includes("DB_PASSWORD: secret123"));
217
+ assert.ok(result.includes("NODE_ENV: staging"));
218
+ });
219
+
220
+ it("restores [REDACTED] from original in list form", () => {
221
+ const edited = ` - DB_PASSWORD=[REDACTED]\n - NODE_ENV=staging`;
222
+ const original = ` - DB_PASSWORD=secret123\n - NODE_ENV=production`;
223
+ const result = restoreRedactedValues(edited, original);
224
+ assert.ok(result.includes("- DB_PASSWORD=secret123"));
225
+ assert.ok(result.includes("- NODE_ENV=staging"));
226
+ });
227
+
228
+ it("leaves non-redacted values as-is", () => {
229
+ const edited = ` NODE_ENV: staging`;
230
+ const original = ` NODE_ENV: production`;
231
+ const result = restoreRedactedValues(edited, original);
232
+ assert.ok(result.includes("NODE_ENV: staging"));
233
+ });
234
+ });
@@ -0,0 +1,93 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { SwarmpitClient } from "../client.js";
4
+ import { toolResult, toolError } from "./helpers.js";
5
+
6
+ export function registerAdminTools(
7
+ server: McpServer,
8
+ client: SwarmpitClient
9
+ ): void {
10
+ server.tool(
11
+ "list_users",
12
+ "List all Swarmpit users (admin)",
13
+ {},
14
+ async () => {
15
+ try {
16
+ const users = await client.listUsers();
17
+ return toolResult(users);
18
+ } catch (e) {
19
+ return toolError(e);
20
+ }
21
+ }
22
+ );
23
+
24
+ server.tool(
25
+ "get_user",
26
+ "Get Swarmpit user details (admin)",
27
+ { id: z.string().describe("User ID") },
28
+ async ({ id }) => {
29
+ try {
30
+ const user = await client.getUser(id);
31
+ return toolResult(user);
32
+ } catch (e) {
33
+ return toolError(e);
34
+ }
35
+ }
36
+ );
37
+
38
+ server.tool(
39
+ "create_user",
40
+ "Create a Swarmpit user (admin)",
41
+ {
42
+ username: z.string().describe("Username"),
43
+ password: z.string().describe("Password"),
44
+ role: z.string().describe("Role (admin or user)"),
45
+ email: z.string().optional().describe("Email address"),
46
+ },
47
+ async ({ username, password, role, email }) => {
48
+ try {
49
+ await client.createUser({ username, password, role, email });
50
+ return toolResult({ created: true, username });
51
+ } catch (e) {
52
+ return toolError(e);
53
+ }
54
+ }
55
+ );
56
+
57
+ server.tool(
58
+ "edit_user",
59
+ "Edit a Swarmpit user (admin)",
60
+ {
61
+ id: z.string().describe("User ID"),
62
+ spec: z.record(z.unknown()).describe("User fields to update (role, email, etc.)"),
63
+ },
64
+ async ({ id, spec }) => {
65
+ try {
66
+ await client.editUser(id, spec);
67
+ return toolResult({ updated: true, id });
68
+ } catch (e) {
69
+ return toolError(e);
70
+ }
71
+ }
72
+ );
73
+
74
+ server.tool(
75
+ "delete_user",
76
+ "Delete a Swarmpit user (admin). DESTRUCTIVE: requires confirm=true",
77
+ {
78
+ id: z.string().describe("User ID"),
79
+ confirm: z.boolean().describe("Must be true to confirm deletion"),
80
+ },
81
+ async ({ id, confirm }) => {
82
+ if (!confirm) {
83
+ return toolError("Destructive operation: set confirm=true to delete this user");
84
+ }
85
+ try {
86
+ await client.deleteUser(id);
87
+ return toolResult({ deleted: true, id });
88
+ } catch (e) {
89
+ return toolError(e);
90
+ }
91
+ }
92
+ );
93
+ }
@@ -0,0 +1,101 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import type { RedactMode } from "../config.js";
4
+ import { SwarmpitClient } from "../client.js";
5
+ import { toolResult, toolError, resolveData } from "./helpers.js";
6
+
7
+ function redactConfig(config: Record<string, unknown>, redact: RedactMode): Record<string, unknown> {
8
+ if (redact === "none") return config;
9
+ const { data, ...rest } = config;
10
+ return { ...rest, data: data ? "[REDACTED]" : undefined };
11
+ }
12
+
13
+ export function registerConfigTools(
14
+ server: McpServer,
15
+ client: SwarmpitClient,
16
+ redact: RedactMode
17
+ ): void {
18
+ server.tool(
19
+ "list_configs",
20
+ "List all Docker Swarm configs",
21
+ {},
22
+ async () => {
23
+ try {
24
+ const configs = await client.listConfigs();
25
+ return toolResult(configs.map((c) => redactConfig(c, redact)));
26
+ } catch (e) {
27
+ return toolError(e);
28
+ }
29
+ }
30
+ );
31
+
32
+ server.tool(
33
+ "get_config",
34
+ "Get details of a specific Docker Swarm config",
35
+ { id: z.string().describe("Config ID or name") },
36
+ async ({ id }) => {
37
+ try {
38
+ const config = await client.getConfig(id);
39
+ return toolResult(redactConfig(config, redact));
40
+ } catch (e) {
41
+ return toolError(e);
42
+ }
43
+ }
44
+ );
45
+
46
+ server.tool(
47
+ "create_config",
48
+ "Create a Docker Swarm config. Data can be plain, { $env: VAR_NAME } to resolve from env, or { $file: /path } to read from a local file (avoids sending large content through LLM context).",
49
+ {
50
+ configName: z.string().describe("Config name"),
51
+ data: z.union([
52
+ z.string(),
53
+ z.object({ $env: z.string() }).describe('Reference to local env var'),
54
+ z.object({ $file: z.string() }).describe('Read from local file path'),
55
+ ]).describe("Config data — string, { $env: VAR_NAME }, or { $file: /path }"),
56
+ },
57
+ async ({ configName, data }) => {
58
+ try {
59
+ const resolved = resolveData(data);
60
+ await client.createConfig({ configName, data: resolved });
61
+ return toolResult({ created: true, configName });
62
+ } catch (e) {
63
+ return toolError(e);
64
+ }
65
+ }
66
+ );
67
+
68
+ server.tool(
69
+ "delete_config",
70
+ "Delete a Docker Swarm config. DESTRUCTIVE: requires confirm=true",
71
+ {
72
+ id: z.string().describe("Config ID or name"),
73
+ confirm: z.boolean().describe("Must be true to confirm deletion"),
74
+ },
75
+ async ({ id, confirm }) => {
76
+ if (!confirm) {
77
+ return toolError("Destructive operation: set confirm=true to delete this config");
78
+ }
79
+ try {
80
+ await client.deleteConfig(id);
81
+ return toolResult({ deleted: true, id });
82
+ } catch (e) {
83
+ return toolError(e);
84
+ }
85
+ }
86
+ );
87
+
88
+ server.tool(
89
+ "get_config_services",
90
+ "List services using a specific config",
91
+ { id: z.string().describe("Config ID or name") },
92
+ async ({ id }) => {
93
+ try {
94
+ const services = await client.getConfigServices(id);
95
+ return toolResult(services);
96
+ } catch (e) {
97
+ return toolError(e);
98
+ }
99
+ }
100
+ );
101
+ }
@@ -0,0 +1,65 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { SwarmpitClient } from "../client.js";
4
+ import { toolResult, toolError } from "./helpers.js";
5
+
6
+ export function registerDashboardTools(
7
+ server: McpServer,
8
+ client: SwarmpitClient
9
+ ): void {
10
+ server.tool(
11
+ "pin_node_to_dashboard",
12
+ "Pin a node to the Swarmpit dashboard",
13
+ { id: z.string().describe("Node ID") },
14
+ async ({ id }) => {
15
+ try {
16
+ await client.pinNodeToDashboard(id);
17
+ return toolResult({ pinned: true, id });
18
+ } catch (e) {
19
+ return toolError(e);
20
+ }
21
+ }
22
+ );
23
+
24
+ server.tool(
25
+ "unpin_node_from_dashboard",
26
+ "Remove a node from the Swarmpit dashboard",
27
+ { id: z.string().describe("Node ID") },
28
+ async ({ id }) => {
29
+ try {
30
+ await client.unpinNodeFromDashboard(id);
31
+ return toolResult({ unpinned: true, id });
32
+ } catch (e) {
33
+ return toolError(e);
34
+ }
35
+ }
36
+ );
37
+
38
+ server.tool(
39
+ "pin_service_to_dashboard",
40
+ "Pin a service to the Swarmpit dashboard",
41
+ { id: z.string().describe("Service ID or name") },
42
+ async ({ id }) => {
43
+ try {
44
+ await client.pinServiceToDashboard(id);
45
+ return toolResult({ pinned: true, id });
46
+ } catch (e) {
47
+ return toolError(e);
48
+ }
49
+ }
50
+ );
51
+
52
+ server.tool(
53
+ "unpin_service_from_dashboard",
54
+ "Remove a service from the Swarmpit dashboard",
55
+ { id: z.string().describe("Service ID or name") },
56
+ async ({ id }) => {
57
+ try {
58
+ await client.unpinServiceFromDashboard(id);
59
+ return toolResult({ unpinned: true, id });
60
+ } catch (e) {
61
+ return toolError(e);
62
+ }
63
+ }
64
+ );
65
+ }
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+
4
+ export function toolResult(data: unknown): CallToolResult {
5
+ return {
6
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
7
+ };
8
+ }
9
+
10
+ export function toolError(error: unknown): CallToolResult {
11
+ const message =
12
+ error instanceof Error ? error.message : String(error);
13
+ return {
14
+ content: [{ type: "text", text: message }],
15
+ isError: true,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Prepare a service object from GET response for use in POST update.
21
+ * - Strips null values (API rejects them for optional fields)
22
+ * - Strips read-only fields not accepted by the edit endpoint
23
+ * - Trims repository to { name, tag } (API rejects imageDigest/image)
24
+ */
25
+ export function prepareServiceForUpdate(service: Record<string, unknown>): Record<string, unknown> {
26
+ const result: Record<string, unknown> = {};
27
+ const readOnlyKeys = new Set(["id", "createdAt", "updatedAt", "state", "status"]);
28
+
29
+ for (const [key, value] of Object.entries(service)) {
30
+ if (value === null || readOnlyKeys.has(key)) continue;
31
+ result[key] = value;
32
+ }
33
+
34
+ // Repository: API only accepts { name, tag }
35
+ const repo = service.repository as Record<string, unknown> | undefined;
36
+ if (repo) {
37
+ result.repository = { name: repo.name, tag: repo.tag };
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ export function resolveEnvRef(
44
+ value: unknown
45
+ ): string {
46
+ if (typeof value === "string") return value;
47
+ if (
48
+ value &&
49
+ typeof value === "object" &&
50
+ "$env" in value &&
51
+ typeof (value as Record<string, unknown>).$env === "string"
52
+ ) {
53
+ const varName = (value as Record<string, string>).$env;
54
+ const resolved = process.env[varName];
55
+ if (!resolved) {
56
+ throw new Error(
57
+ `$env reference "${varName}" is not set in environment`
58
+ );
59
+ }
60
+ return resolved;
61
+ }
62
+ throw new Error(
63
+ `Invalid env var value: expected a string or { "$env": "VAR_NAME" }`
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Resolve a data value that may be a plain string, { $file: path }, or { $env: var }.
69
+ * Used for config/secret data to avoid sending large payloads through the LLM context.
70
+ */
71
+ export function resolveData(value: unknown): string {
72
+ if (typeof value === "string") return value;
73
+ if (value && typeof value === "object") {
74
+ if ("$file" in value && typeof (value as Record<string, unknown>).$file === "string") {
75
+ const path = (value as Record<string, string>).$file;
76
+ try {
77
+ return readFileSync(path, "utf-8");
78
+ } catch (err) {
79
+ throw new Error(
80
+ `$file reference "${path}" could not be read: ${err instanceof Error ? err.message : err}`
81
+ );
82
+ }
83
+ }
84
+ if ("$env" in value && typeof (value as Record<string, unknown>).$env === "string") {
85
+ return resolveEnvRef(value);
86
+ }
87
+ }
88
+ throw new Error(
89
+ 'Invalid data: expected a string, { "$file": "/path" }, or { "$env": "VAR_NAME" }'
90
+ );
91
+ }
@@ -0,0 +1,99 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { SwarmpitClient } from "../client.js";
4
+ import { toolResult, toolError } from "./helpers.js";
5
+
6
+ export function registerNetworkTools(
7
+ server: McpServer,
8
+ client: SwarmpitClient
9
+ ): void {
10
+ server.tool(
11
+ "list_networks",
12
+ "List all Docker Swarm networks",
13
+ {},
14
+ async () => {
15
+ try {
16
+ const networks = await client.listNetworks();
17
+ return toolResult(networks);
18
+ } catch (e) {
19
+ return toolError(e);
20
+ }
21
+ }
22
+ );
23
+
24
+ server.tool(
25
+ "get_network",
26
+ "Get details of a specific Docker Swarm network",
27
+ { id: z.string().describe("Network ID or name") },
28
+ async ({ id }) => {
29
+ try {
30
+ const network = await client.getNetwork(id);
31
+ return toolResult(network);
32
+ } catch (e) {
33
+ return toolError(e);
34
+ }
35
+ }
36
+ );
37
+
38
+ server.tool(
39
+ "create_network",
40
+ "Create a Docker Swarm network",
41
+ {
42
+ networkName: z.string().describe("Network name"),
43
+ driver: z.string().default("overlay").describe("Network driver (default: overlay)"),
44
+ attachable: z.boolean().default(false).describe("Whether containers can attach to this network"),
45
+ internal: z.boolean().default(false).describe("Restrict external access"),
46
+ },
47
+ async ({ networkName, driver, attachable, internal }) => {
48
+ try {
49
+ await client.createNetwork({
50
+ networkName,
51
+ driver,
52
+ attachable,
53
+ internal,
54
+ ingress: false,
55
+ enableIPv6: false,
56
+ ipam: { subnet: "", gateway: "" },
57
+ options: [],
58
+ });
59
+ return toolResult({ created: true, networkName });
60
+ } catch (e) {
61
+ return toolError(e);
62
+ }
63
+ }
64
+ );
65
+
66
+ server.tool(
67
+ "delete_network",
68
+ "Delete a Docker Swarm network. DESTRUCTIVE: requires confirm=true",
69
+ {
70
+ id: z.string().describe("Network ID or name"),
71
+ confirm: z.boolean().describe("Must be true to confirm deletion"),
72
+ },
73
+ async ({ id, confirm }) => {
74
+ if (!confirm) {
75
+ return toolError("Destructive operation: set confirm=true to delete this network");
76
+ }
77
+ try {
78
+ await client.deleteNetwork(id);
79
+ return toolResult({ deleted: true, id });
80
+ } catch (e) {
81
+ return toolError(e);
82
+ }
83
+ }
84
+ );
85
+
86
+ server.tool(
87
+ "get_network_services",
88
+ "List services using a specific network",
89
+ { id: z.string().describe("Network ID or name") },
90
+ async ({ id }) => {
91
+ try {
92
+ const services = await client.getNetworkServices(id);
93
+ return toolResult(services);
94
+ } catch (e) {
95
+ return toolError(e);
96
+ }
97
+ }
98
+ );
99
+ }