@supernova123/docker-mcp-server 0.2.4 → 0.3.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/types.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { z } from "zod";
2
2
 
3
+ // Validation regexes (shared across schemas)
4
+ const SAFE_CMD_ARG = /^[A-Za-z0-9_./:@%+=,\-]+$/;
5
+ const SAFE_PATH = /^\/[A-Za-z0-9_./\-]+$/;
6
+ const SAFE_ENV_KEY = /^[A-Z_][A-Z0-9_]*$/;
7
+ const SAFE_BUILD_CONTEXT = /^\/[A-Za-z0-9_./\-]+$/;
8
+
3
9
  // Container lifecycle schemas
4
10
  export const ListContainersSchema = z.object({
5
11
  all: z.boolean().optional().describe("Include stopped containers (default: false)"),
@@ -59,11 +65,15 @@ export const PullImageSchema = z.object({
59
65
  });
60
66
 
61
67
  export const BuildImageSchema = z.object({
62
- context: z.string().describe("Build context path or Dockerfile content"),
68
+ context: z.string().regex(SAFE_BUILD_CONTEXT, "Build context must be a local absolute path (no URLs, no '..')")
69
+ .max(4096).describe("Build context path (local absolute path only)"),
63
70
  tag: z.string().describe("Tag for the built image (e.g., 'myapp:v1')"),
64
- dockerfile: z.string().optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
65
- build_args: z.record(z.string()).optional().describe("Build arguments"),
66
- target: z.string().optional().describe("Target build stage"),
71
+ dockerfile: z.string().max(256).optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
72
+ build_args: z.record(
73
+ z.string().regex(SAFE_ENV_KEY, "Build arg key must be POSIX-style").max(256),
74
+ z.string().max(4096)
75
+ ).optional().describe("Build arguments (keys must be POSIX-style)"),
76
+ target: z.string().max(256).optional().describe("Target build stage"),
67
77
  });
68
78
 
69
79
  export const RemoveImageSchema = z.object({
@@ -112,8 +122,8 @@ export const CheckHealthSchema = z.object({
112
122
 
113
123
  export const WatchHealthSchema = z.object({
114
124
  container_id: z.string().describe("Container ID or name"),
115
- timeout: z.number().optional().describe("Max seconds to wait (default: 60)"),
116
- interval: z.number().optional().describe("Seconds between polls (default: 5)"),
125
+ timeout: z.number().max(600).optional().describe("Max seconds to wait (default: 60, max: 600)"),
126
+ interval: z.number().max(60).optional().describe("Seconds between polls (default: 5, max: 60)"),
117
127
  });
118
128
 
119
129
  export const SetRestartPolicySchema = z.object({
@@ -125,7 +135,7 @@ export const SetRestartPolicySchema = z.object({
125
135
  // Logs schemas
126
136
  export const StreamLogsSchema = z.object({
127
137
  container_id: z.string().describe("Container ID or name"),
128
- tail: z.number().optional().describe("Number of lines to show (default: 100)"),
138
+ tail: z.number().max(10000).optional().describe("Number of lines to show (default: 100, max: 10000)"),
129
139
  since: z.string().optional().describe("Show logs since timestamp (e.g., '2026-01-01T00:00:00Z')"),
130
140
  follow: z.boolean().optional().describe("Follow log output (default: false)"),
131
141
  });
@@ -134,12 +144,17 @@ export const ContainerStatsSchema = z.object({
134
144
  container_id: z.string().describe("Container ID or name"),
135
145
  });
136
146
 
137
- // Exec schema
147
+ // Exec schema (validated per Finding 8.1 — command/working_dir/env constraints)
138
148
  export const ExecInContainerSchema = z.object({
139
149
  container_id: z.string().describe("Container ID or name"),
140
- command: z.array(z.string()).describe("Command to execute"),
141
- working_dir: z.string().optional().describe("Working directory inside container"),
142
- env: z.record(z.string()).optional().describe("Environment variables"),
150
+ command: z.array(z.string().max(500).regex(SAFE_CMD_ARG, "Command arg contains disallowed characters"))
151
+ .min(1).max(50).describe("Command to execute (max 50 args, alphanumeric + safe chars only)"),
152
+ working_dir: z.string().regex(SAFE_PATH, "Working directory must be an absolute path without '..'")
153
+ .max(1000).optional().describe("Working directory inside container (absolute path)"),
154
+ env: z.record(
155
+ z.string().regex(SAFE_ENV_KEY, "Env key must be POSIX-style (A-Z, 0-9, _)").max(100),
156
+ z.string().max(1000)
157
+ ).optional().describe("Environment variables (keys must be POSIX-style)"),
143
158
  });
144
159
 
145
160
  // Network/Volume schemas
@@ -151,6 +166,26 @@ export const ListVolumesSchema = z.object({
151
166
  filter: z.string().optional().describe("Filter by name or driver"),
152
167
  });
153
168
 
169
+ export const CreateVolumeSchema = z.object({
170
+ name: z.string().min(1).max(255).describe("Volume name"),
171
+ driver: z.string().optional().describe("Volume driver (default: 'local')"),
172
+ labels: z.record(z.string(), z.string()).optional().describe("Labels to apply to the volume"),
173
+ options: z.record(z.string(), z.string()).optional().describe("Driver-specific options"),
174
+ });
175
+
176
+ export const InspectVolumeSchema = z.object({
177
+ name: z.string().min(1).describe("Volume name or ID to inspect"),
178
+ });
179
+
180
+ export const RemoveVolumeSchema = z.object({
181
+ name: z.string().min(1).describe("Volume name or ID to remove"),
182
+ force: z.boolean().optional().describe("Force removal even if in use (default: false)"),
183
+ });
184
+
185
+ export const PruneVolumesSchema = z.object({
186
+ filter: z.string().optional().describe("Filter by label (e.g., 'label=key=value')"),
187
+ });
188
+
154
189
 
155
190
  // Monitoring schemas (v0.2.0)
156
191
  export const ContainerHealthStatusSchema = z.object({});
@@ -163,13 +198,13 @@ export const WatchEventsSchema = z.object({
163
198
  container: z.string().optional().describe("Filter by container name or ID"),
164
199
  event_type: z.enum(["start", "stop", "die", "restart", "health_status", "oom", "all"]).optional().describe("Filter by event type (default: all)"),
165
200
  since: z.string().optional().describe("Show events since timestamp (e.g., '2026-01-01T00:00:00Z')"),
166
- duration: z.number().optional().describe("Max seconds to listen (default: 30)"),
201
+ duration: z.number().max(300).optional().describe("Max seconds to listen (default: 30, max: 300)"),
167
202
  });
168
203
 
169
204
  export const SearchLogsSchema = z.object({
170
- pattern: z.string().describe("Regex or grep pattern to search for"),
171
- containers: z.array(z.string()).optional().describe("Specific containers to search (default: all running)"),
172
- tail: z.number().optional().describe("Max lines to scan per container (default: 500)"),
205
+ pattern: z.string().max(1000).describe("Regex or grep pattern to search for"),
206
+ containers: z.array(z.string()).max(50).optional().describe("Specific containers to search (default: all running, max: 50)"),
207
+ tail: z.number().max(10000).optional().describe("Max lines to scan per container (default: 500, max: 10000)"),
173
208
  since: z.string().optional().describe("Only search logs since timestamp"),
174
209
  ignore_case: z.boolean().optional().describe("Case-insensitive search (default: false)"),
175
210
  });
@@ -28,7 +28,7 @@ import { createDockerClient } from "../src/docker.js";
28
28
  function createMockServer() {
29
29
  const tools: Record<string, { description: string; handler: Function }> = {};
30
30
  return {
31
- tool: (name: string, description: string, _schema: unknown, handler: Function) => {
31
+ tool: (name: string, description: string, _schema: unknown, _hints: unknown, handler: Function) => {
32
32
  tools[name] = { description, handler };
33
33
  },
34
34
  tools,
@@ -27,7 +27,7 @@ import { registerMonitoringTools } from "../src/tools/monitoring.js";
27
27
  function createMockServer() {
28
28
  const tools: Record<string, { description: string; handler: Function }> = {};
29
29
  return {
30
- tool: (name: string, description: string, _schema: unknown, handler: Function) => {
30
+ tool: (name: string, description: string, _schema: unknown, _hints: unknown, handler: Function) => {
31
31
  tools[name] = { description, handler };
32
32
  },
33
33
  tools,
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock Dockerode before importing the module under test
4
+ const mockCreateVolume = vi.fn();
5
+ const mockListVolumes = vi.fn();
6
+ const mockPruneVolumes = vi.fn();
7
+ const mockGetVolume = vi.fn();
8
+
9
+ vi.mock("dockerode", () => {
10
+ return {
11
+ default: vi.fn().mockImplementation(() => ({
12
+ createVolume: mockCreateVolume,
13
+ listVolumes: mockListVolumes,
14
+ pruneVolumes: mockPruneVolumes,
15
+ getVolume: mockGetVolume,
16
+ })),
17
+ };
18
+ });
19
+
20
+ import { registerVolumeTools } from "../src/tools/volume.js";
21
+
22
+ // Minimal MCP server mock
23
+ function createMockServer() {
24
+ const tools: Record<string, { description: string; handler: Function }> = {};
25
+ return {
26
+ tool: (
27
+ name: string,
28
+ description: string,
29
+ _schema: unknown,
30
+ _hints: unknown,
31
+ handler: Function
32
+ ) => {
33
+ tools[name] = { description, handler };
34
+ },
35
+ tools,
36
+ };
37
+ }
38
+
39
+ describe("Volume Tools", () => {
40
+ let server: ReturnType<typeof createMockServer>;
41
+
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ server = createMockServer();
45
+ // Create a fresh docker-like object with direct mock references
46
+ const docker = {
47
+ createVolume: mockCreateVolume,
48
+ listVolumes: mockListVolumes,
49
+ pruneVolumes: mockPruneVolumes,
50
+ getVolume: mockGetVolume,
51
+ } as any;
52
+ registerVolumeTools(server, docker);
53
+ });
54
+
55
+ describe("create_volume", () => {
56
+ it("should create a volume with default driver", async () => {
57
+ mockCreateVolume.mockResolvedValue({
58
+ Name: "test-vol",
59
+ Driver: "local",
60
+ Mountpoint: "/var/lib/docker/volumes/test-vol/_data",
61
+ CreatedAt: "2026-06-13T12:00:00Z",
62
+ Labels: {},
63
+ });
64
+
65
+ const handler = server.tools["create_volume"].handler;
66
+ const result = await handler({ name: "test-vol" });
67
+
68
+ expect(mockCreateVolume).toHaveBeenCalledWith({
69
+ Name: "test-vol",
70
+ Driver: "local",
71
+ Labels: undefined,
72
+ DriverOpts: undefined,
73
+ });
74
+ expect(result.isError).toBeFalsy();
75
+ const data = JSON.parse(result.content[0].text);
76
+ expect(data.name).toBe("test-vol");
77
+ expect(data.driver).toBe("local");
78
+ });
79
+
80
+ it("should create a volume with custom driver and labels", async () => {
81
+ mockCreateVolume.mockResolvedValue({
82
+ Name: "nfs-vol",
83
+ Driver: "nfs",
84
+ Mountpoint: "/var/lib/docker/volumes/nfs-vol/_data",
85
+ Labels: { env: "prod" },
86
+ });
87
+
88
+ const handler = server.tools["create_volume"].handler;
89
+ const result = await handler({
90
+ name: "nfs-vol",
91
+ driver: "nfs",
92
+ labels: { env: "prod" },
93
+ });
94
+
95
+ expect(mockCreateVolume).toHaveBeenCalledWith({
96
+ Name: "nfs-vol",
97
+ Driver: "nfs",
98
+ Labels: { env: "prod" },
99
+ DriverOpts: undefined,
100
+ });
101
+ expect(result.isError).toBeFalsy();
102
+ });
103
+
104
+ it("should handle creation errors", async () => {
105
+ mockCreateVolume.mockRejectedValue(new Error("volume name already exists"));
106
+
107
+ const handler = server.tools["create_volume"].handler;
108
+ const result = await handler({ name: "existing-vol" });
109
+
110
+ expect(result.isError).toBe(true);
111
+ expect(result.content[0].text).toContain("volume name already exists");
112
+ });
113
+ });
114
+
115
+ describe("inspect_volume", () => {
116
+ it("should inspect a volume", async () => {
117
+ const mockInspect = vi.fn().mockResolvedValue({
118
+ Name: "test-vol",
119
+ Driver: "local",
120
+ Mountpoint: "/var/lib/docker/volumes/test-vol/_data",
121
+ Labels: {},
122
+ Scope: "local",
123
+ Options: {},
124
+ Status: null,
125
+ UsageData: null,
126
+ });
127
+ mockGetVolume.mockReturnValue({ inspect: mockInspect });
128
+
129
+ const handler = server.tools["inspect_volume"].handler;
130
+ const result = await handler({ name: "test-vol" });
131
+
132
+ expect(mockGetVolume).toHaveBeenCalledWith("test-vol");
133
+ expect(result.isError).toBeFalsy();
134
+ const data = JSON.parse(result.content[0].text);
135
+ expect(data.name).toBe("test-vol");
136
+ expect(data.scope).toBe("local");
137
+ });
138
+
139
+ it("should handle inspect errors for non-existent volumes", async () => {
140
+ const mockInspect = vi.fn().mockRejectedValue(new Error("No such volume"));
141
+ mockGetVolume.mockReturnValue({ inspect: mockInspect });
142
+
143
+ const handler = server.tools["inspect_volume"].handler;
144
+ const result = await handler({ name: "missing-vol" });
145
+
146
+ expect(result.isError).toBe(true);
147
+ expect(result.content[0].text).toContain("No such volume");
148
+ });
149
+ });
150
+
151
+ describe("remove_volume", () => {
152
+ it("should remove a volume without force", async () => {
153
+ const mockRemove = vi.fn().mockResolvedValue(undefined);
154
+ mockGetVolume.mockReturnValue({ remove: mockRemove });
155
+
156
+ const handler = server.tools["remove_volume"].handler;
157
+ const result = await handler({ name: "old-vol" });
158
+
159
+ expect(mockRemove).toHaveBeenCalledWith({ force: false });
160
+ expect(result.isError).toBeFalsy();
161
+ const data = JSON.parse(result.content[0].text);
162
+ expect(data.success).toBe(true);
163
+ });
164
+
165
+ it("should remove a volume with force", async () => {
166
+ const mockRemove = vi.fn().mockResolvedValue(undefined);
167
+ mockGetVolume.mockReturnValue({ remove: mockRemove });
168
+
169
+ const handler = server.tools["remove_volume"].handler;
170
+ const result = await handler({ name: "in-use-vol", force: true });
171
+
172
+ expect(mockRemove).toHaveBeenCalledWith({ force: true });
173
+ expect(result.isError).toBeFalsy();
174
+ });
175
+
176
+ it("should handle removal errors", async () => {
177
+ const mockRemove = vi.fn().mockRejectedValue(new Error("volume is in use"));
178
+ mockGetVolume.mockReturnValue({ remove: mockRemove });
179
+
180
+ const handler = server.tools["remove_volume"].handler;
181
+ const result = await handler({ name: "busy-vol" });
182
+
183
+ expect(result.isError).toBe(true);
184
+ expect(result.content[0].text).toContain("volume is in use");
185
+ });
186
+ });
187
+
188
+ describe("prune_volumes", () => {
189
+ it("should prune unused volumes", async () => {
190
+ mockPruneVolumes.mockResolvedValue({
191
+ VolumesDeleted: ["vol1", "vol2"],
192
+ SpaceReclaimed: 1024000,
193
+ });
194
+
195
+ const handler = server.tools["prune_volumes"].handler;
196
+ const result = await handler({});
197
+
198
+ expect(mockPruneVolumes).toHaveBeenCalledWith({ filters: undefined });
199
+ expect(result.isError).toBeFalsy();
200
+ const data = JSON.parse(result.content[0].text);
201
+ expect(data.volumes_deleted).toEqual(["vol1", "vol2"]);
202
+ expect(data.space_reclaimed).toBe(1024000);
203
+ });
204
+
205
+ it("should prune with label filter", async () => {
206
+ mockPruneVolumes.mockResolvedValue({
207
+ VolumesDeleted: ["old-cache"],
208
+ SpaceReclaimed: 500000,
209
+ });
210
+
211
+ const handler = server.tools["prune_volumes"].handler;
212
+ const result = await handler({ filter: "label=env=test" });
213
+
214
+ expect(mockPruneVolumes).toHaveBeenCalledWith({
215
+ filters: { label: ["env=test"] },
216
+ });
217
+ expect(result.isError).toBeFalsy();
218
+ });
219
+
220
+ it("should handle prune errors", async () => {
221
+ mockPruneVolumes.mockRejectedValue(new Error("prune failed"));
222
+
223
+ const handler = server.tools["prune_volumes"].handler;
224
+ const result = await handler({});
225
+
226
+ expect(result.isError).toBe(true);
227
+ expect(result.content[0].text).toContain("prune failed");
228
+ });
229
+ });
230
+ });