@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/README.md +11 -1
- package/SECURITY.md +52 -0
- package/dist/docker.d.ts +5 -0
- package/dist/docker.js +19 -0
- package/dist/server.js +3 -1
- package/dist/tools/compose.js +3 -2
- package/dist/tools/exec.js +2 -2
- package/dist/tools/logs.js +3 -2
- package/dist/tools/monitoring.js +2 -1
- package/dist/tools/volume.d.ts +4 -0
- package/dist/tools/volume.js +95 -0
- package/dist/types.d.ts +42 -2
- package/dist/types.js +39 -15
- package/glama.json +12 -0
- package/package.json +1 -1
- package/src/docker.ts +20 -0
- package/src/docker.ts.bak +85 -0
- package/src/server.ts +3 -1
- package/src/tools/compose.ts +3 -2
- package/src/tools/exec.ts +2 -2
- package/src/tools/logs.ts +3 -2
- package/src/tools/monitoring.ts +2 -1
- package/src/tools/monitoring.ts.bak +376 -0
- package/src/tools/volume.ts +125 -0
- package/src/types.ts +50 -15
- package/tests/image.test.ts +1 -1
- package/tests/monitoring.test.ts +1 -1
- package/tests/volume.test.ts +230 -0
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().
|
|
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(
|
|
66
|
-
|
|
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()).
|
|
141
|
-
|
|
142
|
-
|
|
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
|
});
|
package/tests/image.test.ts
CHANGED
|
@@ -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,
|
package/tests/monitoring.test.ts
CHANGED
|
@@ -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
|
+
});
|