Skip to content

Commit a237039

Browse files
committed
fix: swarmLeave timeout, secretCreate/configCreate stdin support, network error regex
- Bump swarmLeave timeout from 30s to 120s (swarm leave can take 60s+) - Add inline data param to secretCreate and configCreate via spawn/stdin - Tighten network-not-found regex in docker-api.ts to avoid false positives - Update input validation tests for new secret/config schemas Made-with: Cursor
1 parent d0beef3 commit a237039

5 files changed

Lines changed: 75 additions & 17 deletions

File tree

mcp-server/src/tools/__tests__/input-validation.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3110,15 +3110,21 @@ describe("docker_stackConfig input validation", () => {
31103110
describe("docker_configCreate input validation", () => {
31113111
const schema = z.object({
31123112
name: z.string().min(1),
3113-
file: z.string().min(1),
3113+
file: z.string().optional(),
3114+
data: z.string().optional(),
31143115
labels: z.array(z.string()).optional(),
31153116
});
31163117

3117-
it("accepts required fields", () => {
3118+
it("accepts with file", () => {
31183119
const result = schema.parse({ name: "my-config", file: "/path/to/config" });
31193120
expect(result.name).toBe("my-config");
31203121
});
31213122

3123+
it("accepts with inline data", () => {
3124+
const result = schema.parse({ name: "my-config", data: "key=value" });
3125+
expect(result.data).toBe("key=value");
3126+
});
3127+
31223128
it("accepts with labels", () => {
31233129
const result = schema.parse({ name: "cfg", file: "f", labels: ["env=prod"] });
31243130
expect(result.labels).toEqual(["env=prod"]);
@@ -3182,22 +3188,28 @@ describe("docker_configRm input validation", () => {
31823188
describe("docker_secretCreate input validation", () => {
31833189
const schema = z.object({
31843190
name: z.string().min(1),
3185-
file: z.string().min(1),
3191+
file: z.string().optional(),
3192+
data: z.string().optional(),
31863193
labels: z.array(z.string()).optional(),
31873194
});
31883195

3189-
it("accepts required fields", () => {
3196+
it("accepts with file", () => {
31903197
const result = schema.parse({ name: "my-secret", file: "/path/to/secret" });
31913198
expect(result.name).toBe("my-secret");
31923199
});
31933200

3201+
it("accepts with inline data", () => {
3202+
const result = schema.parse({ name: "my-secret", data: "s3cret" });
3203+
expect(result.data).toBe("s3cret");
3204+
});
3205+
31943206
it("accepts with labels", () => {
31953207
const result = schema.parse({ name: "sec", file: "f", labels: ["env=prod"] });
31963208
expect(result.labels).toEqual(["env=prod"]);
31973209
});
31983210

31993211
it("rejects empty name", () => {
3200-
expect(() => schema.parse({ name: "", file: "f" })).toThrow();
3212+
expect(() => schema.parse({ name: "", data: "x" })).toThrow();
32013213
});
32023214
});
32033215

mcp-server/src/tools/configCreate.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,47 @@
11
import { z } from "zod";
22
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3-
import { execDocker, errorResponse } from "../utils/docker-api.js";
3+
import { errorResponse } from "../utils/docker-api.js";
4+
import { spawn } from "node:child_process";
45

56
const inputSchema = {
67
name: z.string().min(1).describe("Config name"),
7-
file: z.string().min(1).describe("Path to the config file"),
8+
file: z.string().optional().describe("Path to the config file (omit if providing data)"),
9+
data: z.string().optional().describe("Inline config value (piped via stdin)"),
810
labels: z.array(z.string()).optional().describe("Labels (e.g. ['env=production'])"),
911
};
1012

1113
export function register(server: McpServer): void {
1214
server.tool(
1315
"docker_configCreate",
14-
"Create a Docker Swarm config from a file",
16+
"Create a Docker Swarm config from a file or inline data",
1517
inputSchema,
1618
async (args) => {
1719
try {
20+
if (!args.file && !args.data) {
21+
return errorResponse(new Error("Either 'file' or 'data' must be provided"));
22+
}
23+
1824
const cmdArgs = ["config", "create"];
1925
if (args.labels) for (const l of args.labels) cmdArgs.push("--label", l);
20-
cmdArgs.push(args.name, args.file);
21-
const output = await execDocker(cmdArgs);
26+
cmdArgs.push(args.name, args.file ?? "-");
27+
28+
const output = await new Promise<string>((resolve, reject) => {
29+
const proc = spawn("docker", cmdArgs, { stdio: ["pipe", "pipe", "pipe"] });
30+
let stdout = "";
31+
let stderr = "";
32+
proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
33+
proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
34+
proc.on("error", reject);
35+
proc.on("close", (code) => {
36+
if (code === 0) resolve(stdout);
37+
else reject(new Error(stderr || `docker config create exited with code ${code}`));
38+
});
39+
if (args.data) {
40+
proc.stdin.write(args.data);
41+
}
42+
proc.stdin.end();
43+
});
44+
2245
return { content: [{ type: "text" as const, text: output.trim() || `Config '${args.name}' created` }] };
2346
} catch (error) {
2447
return errorResponse(error);

mcp-server/src/tools/secretCreate.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,47 @@
11
import { z } from "zod";
22
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3-
import { execDocker, errorResponse } from "../utils/docker-api.js";
3+
import { errorResponse } from "../utils/docker-api.js";
4+
import { spawn } from "node:child_process";
45

56
const inputSchema = {
67
name: z.string().min(1).describe("Secret name"),
7-
file: z.string().min(1).describe("Path to the secret file"),
8+
file: z.string().optional().describe("Path to the secret file (omit if providing data)"),
9+
data: z.string().optional().describe("Inline secret value (piped via stdin)"),
810
labels: z.array(z.string()).optional().describe("Labels (e.g. ['env=production'])"),
911
};
1012

1113
export function register(server: McpServer): void {
1214
server.tool(
1315
"docker_secretCreate",
14-
"Create a Docker Swarm secret from a file",
16+
"Create a Docker Swarm secret from a file or inline data",
1517
inputSchema,
1618
async (args) => {
1719
try {
20+
if (!args.file && !args.data) {
21+
return errorResponse(new Error("Either 'file' or 'data' must be provided"));
22+
}
23+
1824
const cmdArgs = ["secret", "create"];
1925
if (args.labels) for (const l of args.labels) cmdArgs.push("--label", l);
20-
cmdArgs.push(args.name, args.file);
21-
const output = await execDocker(cmdArgs);
26+
cmdArgs.push(args.name, args.file ?? "-");
27+
28+
const output = await new Promise<string>((resolve, reject) => {
29+
const proc = spawn("docker", cmdArgs, { stdio: ["pipe", "pipe", "pipe"] });
30+
let stdout = "";
31+
let stderr = "";
32+
proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
33+
proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
34+
proc.on("error", reject);
35+
proc.on("close", (code) => {
36+
if (code === 0) resolve(stdout);
37+
else reject(new Error(stderr || `docker secret create exited with code ${code}`));
38+
});
39+
if (args.data) {
40+
proc.stdin.write(args.data);
41+
}
42+
proc.stdin.end();
43+
});
44+
2245
return { content: [{ type: "text" as const, text: output.trim() || `Secret '${args.name}' created` }] };
2346
} catch (error) {
2447
return errorResponse(error);

mcp-server/src/tools/swarmLeave.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function register(server: McpServer): void {
1515
try {
1616
const cmdArgs = ["swarm", "leave"];
1717
if (args.force) cmdArgs.push("--force");
18-
const output = await execDocker(cmdArgs);
18+
const output = await execDocker(cmdArgs, { timeout: 120_000 });
1919
return { content: [{ type: "text" as const, text: output.trim() || "Node left the swarm" }] };
2020
} catch (error) {
2121
return errorResponse(error);

mcp-server/src/utils/docker-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export async function execDocker(
104104
throw new VolumeNotFoundError(match?.[1] ?? "unknown");
105105
}
106106

107-
if (stderr.includes("No such network") || stderr.includes("network") && stderr.includes("not found")) {
107+
if (stderr.includes("No such network") || /network\s+\S+\s+not found/.test(stderr)) {
108108
const match = stderr.match(/No such network:\s*(\S+)/) ?? stderr.match(/network\s+(\S+)\s+not found/);
109109
throw new NetworkNotFoundError(match?.[1] ?? "unknown");
110110
}

0 commit comments

Comments
 (0)