@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/README.md
CHANGED
|
@@ -141,11 +141,21 @@ claude mcp add docker -- npx -y @supernova123/docker-mcp-server
|
|
|
141
141
|
|
|
142
142
|
## Security
|
|
143
143
|
|
|
144
|
+
This server has **full Docker daemon access** via the Docker socket. It is designed for local development and trusted environments.
|
|
145
|
+
|
|
144
146
|
- **Read-only by default**: all container and image tools read state; write operations (start/stop/remove) require explicit tool calls
|
|
145
|
-
- **No API keys needed**: connects to local Docker
|
|
147
|
+
- **No API keys needed**: connects to local Docker socket (`/var/run/docker.sock`), not remote API tokens
|
|
146
148
|
- **No network access**: all operations are local Docker API calls
|
|
149
|
+
- **Input validation**: Zod schemas on every tool parameter — command injection, path traversal, and env injection are rejected at the schema level
|
|
150
|
+
- **Output sanitization**: ANSI escape codes, invisible Unicode, and Docker stream headers are stripped from all tool output
|
|
151
|
+
- **Output size caps**: log output capped at 100KB, general output at 1MB, to prevent LLM context overflow
|
|
152
|
+
- **Parameter bounds**: command arrays limited to 50 args, env to 50 vars, log tail to 10K lines, timeouts enforced (600s health, 300s events)
|
|
147
153
|
- **MIT License**: fully auditable
|
|
148
154
|
|
|
155
|
+
**Threat model**: any tool that calls Docker through this server can start any container with any flags, including privileged. The threat model is the same as giving a user shell access to the Docker socket. Do not expose this server to untrusted users.
|
|
156
|
+
|
|
157
|
+
For vulnerability reports, see [SECURITY.md](SECURITY.md).
|
|
158
|
+
|
|
149
159
|
## License
|
|
150
160
|
|
|
151
161
|
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
If you discover a security vulnerability in this project, please report it responsibly.
|
|
6
|
+
|
|
7
|
+
**Email:** security@friendlygeorge.org
|
|
8
|
+
|
|
9
|
+
Please do not file public GitHub issues for security bugs.
|
|
10
|
+
|
|
11
|
+
## Supported Versions
|
|
12
|
+
|
|
13
|
+
The latest release on npm (`@supernova123/docker-mcp-server`) is the only supported version. Security fixes are applied to the latest release only.
|
|
14
|
+
|
|
15
|
+
## Threat Model
|
|
16
|
+
|
|
17
|
+
This server has **full Docker daemon access** via the Docker socket (`/var/run/docker.sock`). It is designed for **local development and trusted environments**.
|
|
18
|
+
|
|
19
|
+
**In scope:**
|
|
20
|
+
- Input validation on all tool parameters (command injection, path traversal, env injection)
|
|
21
|
+
- Output sanitization (ANSI escape stripping, invisible Unicode removal, output size caps)
|
|
22
|
+
- Schema-level bounds on all numeric and array parameters
|
|
23
|
+
|
|
24
|
+
**Out of scope:**
|
|
25
|
+
- Exposing this server to untrusted users or the public internet
|
|
26
|
+
- Running in multi-tenant environments without additional isolation
|
|
27
|
+
- Docker socket access control (by design, the server needs full daemon access)
|
|
28
|
+
|
|
29
|
+
**Key security properties:**
|
|
30
|
+
- No embedded credentials — the server uses the Docker socket, not API tokens
|
|
31
|
+
- No network egress — all communication is local Docker API calls
|
|
32
|
+
- Zod input validation on every tool parameter
|
|
33
|
+
- Output sanitization prevents prompt injection via command output
|
|
34
|
+
- Read-only tools marked with `readOnlyHint: true` in MCP metadata
|
|
35
|
+
|
|
36
|
+
## Security Hardening (v0.2.5+)
|
|
37
|
+
|
|
38
|
+
Starting with v0.2.5, the server includes:
|
|
39
|
+
- **Command validation:** `exec_in_container` rejects shell metacharacters and enforces POSIX path rules
|
|
40
|
+
- **Path validation:** `build_image` restricts build context to local absolute paths (no URLs)
|
|
41
|
+
- **Output sanitization:** ANSI escapes, invisible Unicode, and Docker stream headers are stripped
|
|
42
|
+
- **Output size caps:** Log output capped at 100KB, general output at 1MB
|
|
43
|
+
- **Parameter bounds:** Command arrays limited to 50 args, env to 50 vars, log tail to 10K lines
|
|
44
|
+
- **Timeout enforcement:** Health watch (600s max), event listening (300s max)
|
|
45
|
+
|
|
46
|
+
## Dependencies
|
|
47
|
+
|
|
48
|
+
Run `npm audit` regularly. The CI pipeline includes `npm audit --audit-level=high` on every push.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT — see [LICENSE](LICENSE) for details.
|
package/dist/docker.d.ts
CHANGED
|
@@ -8,5 +8,10 @@ export declare function createDockerClient(options?: DockerClientOptions): Docke
|
|
|
8
8
|
export declare function formatError(error: unknown): string;
|
|
9
9
|
export declare function formatContainer(container: Dockerode.ContainerInfo): Record<string, unknown>;
|
|
10
10
|
export declare function formatImage(image: Dockerode.ImageInfo): Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
|
|
13
|
+
* Prevents prompt injection via output and caps LLM context cost.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sanitizeOutput(text: string, maxLength?: number): string;
|
|
11
16
|
export declare function formatBytes(bytes: number): string;
|
|
12
17
|
//# sourceMappingURL=docker.d.ts.map
|
package/dist/docker.js
CHANGED
|
@@ -47,6 +47,25 @@ export function formatImage(image) {
|
|
|
47
47
|
created: new Date(image.Created).toISOString(),
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
|
|
52
|
+
* Prevents prompt injection via output and caps LLM context cost.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeOutput(text, maxLength = 1_000_000) {
|
|
55
|
+
// Strip ANSI escape codes (CSI, OSC, simple sequences)
|
|
56
|
+
text = text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
57
|
+
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
58
|
+
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
59
|
+
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
60
|
+
text = text.replace(/[\u{E0000}-\u{E007F}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu, "");
|
|
61
|
+
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
62
|
+
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
63
|
+
// Truncate to cap memory and LLM context cost
|
|
64
|
+
if (text.length > maxLength) {
|
|
65
|
+
return text.slice(0, maxLength) + `\n... [output truncated at ${maxLength} chars]`;
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
50
69
|
export function formatBytes(bytes) {
|
|
51
70
|
if (bytes === 0)
|
|
52
71
|
return "0 B";
|
package/dist/server.js
CHANGED
|
@@ -6,11 +6,12 @@ import { registerHealthTools } from "./tools/health.js";
|
|
|
6
6
|
import { registerLogsTools } from "./tools/logs.js";
|
|
7
7
|
import { registerExecTools } from "./tools/exec.js";
|
|
8
8
|
import { registerNetworkTools } from "./tools/network.js";
|
|
9
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
9
10
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
10
11
|
export function createServer(docker) {
|
|
11
12
|
const server = new McpServer({
|
|
12
13
|
name: "docker-mcp-server",
|
|
13
|
-
version: "0.
|
|
14
|
+
version: "0.3.0",
|
|
14
15
|
});
|
|
15
16
|
// Register all tool categories
|
|
16
17
|
registerContainerTools(server, docker);
|
|
@@ -20,6 +21,7 @@ export function createServer(docker) {
|
|
|
20
21
|
registerLogsTools(server, docker);
|
|
21
22
|
registerExecTools(server, docker);
|
|
22
23
|
registerNetworkTools(server, docker);
|
|
24
|
+
registerVolumeTools(server, docker);
|
|
23
25
|
registerMonitoringTools(server, docker);
|
|
24
26
|
return server;
|
|
25
27
|
}
|
package/dist/tools/compose.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, statSync } from "fs";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { promisify } from "util";
|
|
5
5
|
import { ComposeUpSchema, ComposeDownSchema, ComposePsSchema, ComposeLogsSchema, ComposeRestartSchema, } from "../types.js";
|
|
6
|
-
import { formatError } from "../docker.js";
|
|
6
|
+
import { formatError, sanitizeOutput } from "../docker.js";
|
|
7
7
|
const execAsync = promisify(execCb);
|
|
8
8
|
const COMPOSE_FILE_NAMES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
|
|
9
9
|
function resolveComposePath(inputPath) {
|
|
@@ -92,7 +92,8 @@ export function registerComposeTools(server) {
|
|
|
92
92
|
if (params.services?.length)
|
|
93
93
|
args.push(...params.services);
|
|
94
94
|
const output = runCompose(params.path, args);
|
|
95
|
-
|
|
95
|
+
// Use 100KB cap for log output to keep LLM context small
|
|
96
|
+
return { content: [{ type: "text", text: sanitizeOutput(output, 100_000) || "No logs found." }] };
|
|
96
97
|
}
|
|
97
98
|
catch (error) {
|
|
98
99
|
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
package/dist/tools/exec.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ExecInContainerSchema } from "../types.js";
|
|
2
|
-
import { formatError } from "../docker.js";
|
|
2
|
+
import { formatError, sanitizeOutput } from "../docker.js";
|
|
3
3
|
export function registerExecTools(server, docker) {
|
|
4
4
|
server.tool("exec_in_container", "Execute a command inside a running Docker container. Returns stdout, stderr, and exit code.", ExecInContainerSchema.shape, { openWorldHint: false }, async (params) => {
|
|
5
5
|
try {
|
|
@@ -18,7 +18,7 @@ export function registerExecTools(server, docker) {
|
|
|
18
18
|
stream.on("end", () => resolve(data));
|
|
19
19
|
});
|
|
20
20
|
const inspect = await exec.inspect();
|
|
21
|
-
const cleanOutput = output
|
|
21
|
+
const cleanOutput = sanitizeOutput(output);
|
|
22
22
|
return {
|
|
23
23
|
content: [{
|
|
24
24
|
type: "text",
|
package/dist/tools/logs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
|
|
2
|
-
import { formatError, formatBytes } from "../docker.js";
|
|
2
|
+
import { formatError, formatBytes, sanitizeOutput } from "../docker.js";
|
|
3
3
|
export function registerLogsTools(server, docker) {
|
|
4
4
|
server.tool("stream_logs", "Get logs from a single Docker container by ID or name. Use stream_logs for one container; use compose_logs for multi-service Compose stacks. Supports tail count (default 100 lines), since timestamp for filtering, and follow mode. Returns UTF-8 log text with multiplexed stream headers stripped, or 'No logs found.' when the container has no output. Read-only and safe to call repeatedly. Returns an error string if the container does not exist.", StreamLogsSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
5
|
try {
|
|
@@ -12,7 +12,8 @@ export function registerLogsTools(server, docker) {
|
|
|
12
12
|
follow: false,
|
|
13
13
|
});
|
|
14
14
|
// Dockerode returns a Buffer with multiplexed stream headers
|
|
15
|
-
|
|
15
|
+
// Use 100KB cap for logs to keep LLM context small
|
|
16
|
+
const output = sanitizeOutput(logs.toString("utf-8"), 100_000);
|
|
16
17
|
return { content: [{ type: "text", text: output || "No logs found." }] };
|
|
17
18
|
}
|
|
18
19
|
catch (error) {
|
package/dist/tools/monitoring.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ContainerHealthStatusSchema, ContainerResourceUsageSchema, WatchEventsSchema, SearchLogsSchema, ResourceAlertCheckSchema, MonitorDashboardSchema, } from "../types.js";
|
|
2
|
+
import { sanitizeOutput } from "../docker.js";
|
|
2
3
|
export function registerMonitoringTools(server, docker) {
|
|
3
4
|
// 1. fleet_status — health status of all running containers
|
|
4
5
|
server.tool("container_health_status", "Check health status, uptime, and restart count for all running Docker containers. Returns JSON with container name, state, health probe status (healthy/unhealthy/no-healthcheck), and restart count. Use this for a quick fleet health overview; for resource metrics use container_resource_usage instead. Returns an array of objects with name, id, state, status, health, uptime, restartCount, and image fields. Read-only and safe to call repeatedly.", ContainerHealthStatusSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
@@ -146,7 +147,7 @@ export function registerMonitoringTools(server, docker) {
|
|
|
146
147
|
tail: params.tail || 500,
|
|
147
148
|
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
148
149
|
});
|
|
149
|
-
const output = logStream.toString("utf-8")
|
|
150
|
+
const output = sanitizeOutput(logStream.toString("utf-8"), 100_000);
|
|
150
151
|
const lines = output.split("\n");
|
|
151
152
|
for (const line of lines) {
|
|
152
153
|
if (regex.test(line)) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CreateVolumeSchema, InspectVolumeSchema, RemoveVolumeSchema, PruneVolumesSchema, } from "../types.js";
|
|
2
|
+
import { formatError } from "../docker.js";
|
|
3
|
+
export function registerVolumeTools(server, docker) {
|
|
4
|
+
server.tool("create_volume", "Create a Docker volume with optional driver, labels, and options. Returns volume name, driver, and mountpoint.", CreateVolumeSchema.shape, { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
|
+
try {
|
|
6
|
+
const result = await docker.createVolume({
|
|
7
|
+
Name: params.name,
|
|
8
|
+
Driver: params.driver || "local",
|
|
9
|
+
Labels: params.labels,
|
|
10
|
+
DriverOpts: params.options,
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
content: [{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: JSON.stringify({
|
|
16
|
+
name: result.Name,
|
|
17
|
+
driver: result.Driver,
|
|
18
|
+
mountpoint: result.Mountpoint,
|
|
19
|
+
created: result.CreatedAt,
|
|
20
|
+
labels: result.Labels,
|
|
21
|
+
}, null, 2),
|
|
22
|
+
}],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
server.tool("inspect_volume", "Inspect a Docker volume. Returns detailed info including name, driver, mountpoint, labels, scope, and usage data.", InspectVolumeSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
30
|
+
try {
|
|
31
|
+
const volume = docker.getVolume(params.name);
|
|
32
|
+
const info = await volume.inspect();
|
|
33
|
+
return {
|
|
34
|
+
content: [{
|
|
35
|
+
type: "text",
|
|
36
|
+
text: JSON.stringify({
|
|
37
|
+
name: info.Name,
|
|
38
|
+
driver: info.Driver,
|
|
39
|
+
mountpoint: info.Mountpoint,
|
|
40
|
+
labels: info.Labels,
|
|
41
|
+
scope: info.Scope,
|
|
42
|
+
options: info.Options,
|
|
43
|
+
status: info.Status || null,
|
|
44
|
+
usage: info.UsageData || null,
|
|
45
|
+
}, null, 2),
|
|
46
|
+
}],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
server.tool("remove_volume", "Remove a Docker volume. Use force=true to remove even if in use by containers.", RemoveVolumeSchema.shape, { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
54
|
+
try {
|
|
55
|
+
const volume = docker.getVolume(params.name);
|
|
56
|
+
await volume.remove({ force: params.force || false });
|
|
57
|
+
return {
|
|
58
|
+
content: [{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: JSON.stringify({ success: true, name: params.name, message: `Volume '${params.name}' removed` }),
|
|
61
|
+
}],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
server.tool("prune_volumes", "Remove all unused Docker volumes. Returns count of removed volumes and reclaimed space.", PruneVolumesSchema.shape, { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async (params) => {
|
|
69
|
+
try {
|
|
70
|
+
const filters = {};
|
|
71
|
+
if (params.filter) {
|
|
72
|
+
const match = params.filter.match(/label=(.+)/);
|
|
73
|
+
if (match) {
|
|
74
|
+
filters.label = [match[1]];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const result = await docker.pruneVolumes({
|
|
78
|
+
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
content: [{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: JSON.stringify({
|
|
84
|
+
volumes_deleted: result.VolumesDeleted || [],
|
|
85
|
+
space_reclaimed: result.SpaceReclaimed || 0,
|
|
86
|
+
}, null, 2),
|
|
87
|
+
}],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=volume.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -306,6 +306,46 @@ export declare const ListVolumesSchema: z.ZodObject<{
|
|
|
306
306
|
}, {
|
|
307
307
|
filter?: string | undefined;
|
|
308
308
|
}>;
|
|
309
|
+
export declare const CreateVolumeSchema: z.ZodObject<{
|
|
310
|
+
name: z.ZodString;
|
|
311
|
+
driver: z.ZodOptional<z.ZodString>;
|
|
312
|
+
labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
313
|
+
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
314
|
+
}, "strip", z.ZodTypeAny, {
|
|
315
|
+
name: string;
|
|
316
|
+
labels?: Record<string, string> | undefined;
|
|
317
|
+
options?: Record<string, string> | undefined;
|
|
318
|
+
driver?: string | undefined;
|
|
319
|
+
}, {
|
|
320
|
+
name: string;
|
|
321
|
+
labels?: Record<string, string> | undefined;
|
|
322
|
+
options?: Record<string, string> | undefined;
|
|
323
|
+
driver?: string | undefined;
|
|
324
|
+
}>;
|
|
325
|
+
export declare const InspectVolumeSchema: z.ZodObject<{
|
|
326
|
+
name: z.ZodString;
|
|
327
|
+
}, "strip", z.ZodTypeAny, {
|
|
328
|
+
name: string;
|
|
329
|
+
}, {
|
|
330
|
+
name: string;
|
|
331
|
+
}>;
|
|
332
|
+
export declare const RemoveVolumeSchema: z.ZodObject<{
|
|
333
|
+
name: z.ZodString;
|
|
334
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
335
|
+
}, "strip", z.ZodTypeAny, {
|
|
336
|
+
name: string;
|
|
337
|
+
force?: boolean | undefined;
|
|
338
|
+
}, {
|
|
339
|
+
name: string;
|
|
340
|
+
force?: boolean | undefined;
|
|
341
|
+
}>;
|
|
342
|
+
export declare const PruneVolumesSchema: z.ZodObject<{
|
|
343
|
+
filter: z.ZodOptional<z.ZodString>;
|
|
344
|
+
}, "strip", z.ZodTypeAny, {
|
|
345
|
+
filter?: string | undefined;
|
|
346
|
+
}, {
|
|
347
|
+
filter?: string | undefined;
|
|
348
|
+
}>;
|
|
309
349
|
export declare const ContainerHealthStatusSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
310
350
|
export declare const ContainerResourceUsageSchema: z.ZodObject<{
|
|
311
351
|
sort_by: z.ZodOptional<z.ZodEnum<["cpu", "memory", "network"]>>;
|
|
@@ -320,15 +360,15 @@ export declare const WatchEventsSchema: z.ZodObject<{
|
|
|
320
360
|
since: z.ZodOptional<z.ZodString>;
|
|
321
361
|
duration: z.ZodOptional<z.ZodNumber>;
|
|
322
362
|
}, "strip", z.ZodTypeAny, {
|
|
363
|
+
duration?: number | undefined;
|
|
323
364
|
since?: string | undefined;
|
|
324
365
|
container?: string | undefined;
|
|
325
366
|
event_type?: "all" | "start" | "stop" | "die" | "restart" | "health_status" | "oom" | undefined;
|
|
326
|
-
duration?: number | undefined;
|
|
327
367
|
}, {
|
|
368
|
+
duration?: number | undefined;
|
|
328
369
|
since?: string | undefined;
|
|
329
370
|
container?: string | undefined;
|
|
330
371
|
event_type?: "all" | "start" | "stop" | "die" | "restart" | "health_status" | "oom" | undefined;
|
|
331
|
-
duration?: number | undefined;
|
|
332
372
|
}>;
|
|
333
373
|
export declare const SearchLogsSchema: z.ZodObject<{
|
|
334
374
|
pattern: z.ZodString;
|
package/dist/types.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
// Validation regexes (shared across schemas)
|
|
3
|
+
const SAFE_CMD_ARG = /^[A-Za-z0-9_./:@%+=,\-]+$/;
|
|
4
|
+
const SAFE_PATH = /^\/[A-Za-z0-9_./\-]+$/;
|
|
5
|
+
const SAFE_ENV_KEY = /^[A-Z_][A-Z0-9_]*$/;
|
|
6
|
+
const SAFE_BUILD_CONTEXT = /^\/[A-Za-z0-9_./\-]+$/;
|
|
2
7
|
// Container lifecycle schemas
|
|
3
8
|
export const ListContainersSchema = z.object({
|
|
4
9
|
all: z.boolean().optional().describe("Include stopped containers (default: false)"),
|
|
@@ -48,11 +53,12 @@ export const PullImageSchema = z.object({
|
|
|
48
53
|
tag: z.string().optional().describe("Tag to pull (default: 'latest')"),
|
|
49
54
|
});
|
|
50
55
|
export const BuildImageSchema = z.object({
|
|
51
|
-
context: z.string().
|
|
56
|
+
context: z.string().regex(SAFE_BUILD_CONTEXT, "Build context must be a local absolute path (no URLs, no '..')")
|
|
57
|
+
.max(4096).describe("Build context path (local absolute path only)"),
|
|
52
58
|
tag: z.string().describe("Tag for the built image (e.g., 'myapp:v1')"),
|
|
53
|
-
dockerfile: z.string().optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
|
|
54
|
-
build_args: z.record(z.string()).optional().describe("Build arguments"),
|
|
55
|
-
target: z.string().optional().describe("Target build stage"),
|
|
59
|
+
dockerfile: z.string().max(256).optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
|
|
60
|
+
build_args: z.record(z.string().regex(SAFE_ENV_KEY, "Build arg key must be POSIX-style").max(256), z.string().max(4096)).optional().describe("Build arguments (keys must be POSIX-style)"),
|
|
61
|
+
target: z.string().max(256).optional().describe("Target build stage"),
|
|
56
62
|
});
|
|
57
63
|
export const RemoveImageSchema = z.object({
|
|
58
64
|
image: z.string().describe("Image name or ID"),
|
|
@@ -93,8 +99,8 @@ export const CheckHealthSchema = z.object({
|
|
|
93
99
|
});
|
|
94
100
|
export const WatchHealthSchema = z.object({
|
|
95
101
|
container_id: z.string().describe("Container ID or name"),
|
|
96
|
-
timeout: z.number().optional().describe("Max seconds to wait (default: 60)"),
|
|
97
|
-
interval: z.number().optional().describe("Seconds between polls (default: 5)"),
|
|
102
|
+
timeout: z.number().max(600).optional().describe("Max seconds to wait (default: 60, max: 600)"),
|
|
103
|
+
interval: z.number().max(60).optional().describe("Seconds between polls (default: 5, max: 60)"),
|
|
98
104
|
});
|
|
99
105
|
export const SetRestartPolicySchema = z.object({
|
|
100
106
|
container_id: z.string().describe("Container ID or name"),
|
|
@@ -104,19 +110,21 @@ export const SetRestartPolicySchema = z.object({
|
|
|
104
110
|
// Logs schemas
|
|
105
111
|
export const StreamLogsSchema = z.object({
|
|
106
112
|
container_id: z.string().describe("Container ID or name"),
|
|
107
|
-
tail: z.number().optional().describe("Number of lines to show (default: 100)"),
|
|
113
|
+
tail: z.number().max(10000).optional().describe("Number of lines to show (default: 100, max: 10000)"),
|
|
108
114
|
since: z.string().optional().describe("Show logs since timestamp (e.g., '2026-01-01T00:00:00Z')"),
|
|
109
115
|
follow: z.boolean().optional().describe("Follow log output (default: false)"),
|
|
110
116
|
});
|
|
111
117
|
export const ContainerStatsSchema = z.object({
|
|
112
118
|
container_id: z.string().describe("Container ID or name"),
|
|
113
119
|
});
|
|
114
|
-
// Exec schema
|
|
120
|
+
// Exec schema (validated per Finding 8.1 — command/working_dir/env constraints)
|
|
115
121
|
export const ExecInContainerSchema = z.object({
|
|
116
122
|
container_id: z.string().describe("Container ID or name"),
|
|
117
|
-
command: z.array(z.string()).
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
command: z.array(z.string().max(500).regex(SAFE_CMD_ARG, "Command arg contains disallowed characters"))
|
|
124
|
+
.min(1).max(50).describe("Command to execute (max 50 args, alphanumeric + safe chars only)"),
|
|
125
|
+
working_dir: z.string().regex(SAFE_PATH, "Working directory must be an absolute path without '..'")
|
|
126
|
+
.max(1000).optional().describe("Working directory inside container (absolute path)"),
|
|
127
|
+
env: z.record(z.string().regex(SAFE_ENV_KEY, "Env key must be POSIX-style (A-Z, 0-9, _)").max(100), z.string().max(1000)).optional().describe("Environment variables (keys must be POSIX-style)"),
|
|
120
128
|
});
|
|
121
129
|
// Network/Volume schemas
|
|
122
130
|
export const ListNetworksSchema = z.object({
|
|
@@ -125,6 +133,22 @@ export const ListNetworksSchema = z.object({
|
|
|
125
133
|
export const ListVolumesSchema = z.object({
|
|
126
134
|
filter: z.string().optional().describe("Filter by name or driver"),
|
|
127
135
|
});
|
|
136
|
+
export const CreateVolumeSchema = z.object({
|
|
137
|
+
name: z.string().min(1).max(255).describe("Volume name"),
|
|
138
|
+
driver: z.string().optional().describe("Volume driver (default: 'local')"),
|
|
139
|
+
labels: z.record(z.string(), z.string()).optional().describe("Labels to apply to the volume"),
|
|
140
|
+
options: z.record(z.string(), z.string()).optional().describe("Driver-specific options"),
|
|
141
|
+
});
|
|
142
|
+
export const InspectVolumeSchema = z.object({
|
|
143
|
+
name: z.string().min(1).describe("Volume name or ID to inspect"),
|
|
144
|
+
});
|
|
145
|
+
export const RemoveVolumeSchema = z.object({
|
|
146
|
+
name: z.string().min(1).describe("Volume name or ID to remove"),
|
|
147
|
+
force: z.boolean().optional().describe("Force removal even if in use (default: false)"),
|
|
148
|
+
});
|
|
149
|
+
export const PruneVolumesSchema = z.object({
|
|
150
|
+
filter: z.string().optional().describe("Filter by label (e.g., 'label=key=value')"),
|
|
151
|
+
});
|
|
128
152
|
// Monitoring schemas (v0.2.0)
|
|
129
153
|
export const ContainerHealthStatusSchema = z.object({});
|
|
130
154
|
export const ContainerResourceUsageSchema = z.object({
|
|
@@ -134,12 +158,12 @@ export const WatchEventsSchema = z.object({
|
|
|
134
158
|
container: z.string().optional().describe("Filter by container name or ID"),
|
|
135
159
|
event_type: z.enum(["start", "stop", "die", "restart", "health_status", "oom", "all"]).optional().describe("Filter by event type (default: all)"),
|
|
136
160
|
since: z.string().optional().describe("Show events since timestamp (e.g., '2026-01-01T00:00:00Z')"),
|
|
137
|
-
duration: z.number().optional().describe("Max seconds to listen (default: 30)"),
|
|
161
|
+
duration: z.number().max(300).optional().describe("Max seconds to listen (default: 30, max: 300)"),
|
|
138
162
|
});
|
|
139
163
|
export const SearchLogsSchema = z.object({
|
|
140
|
-
pattern: z.string().describe("Regex or grep pattern to search for"),
|
|
141
|
-
containers: z.array(z.string()).optional().describe("Specific containers to search (default: all running)"),
|
|
142
|
-
tail: z.number().optional().describe("Max lines to scan per container (default: 500)"),
|
|
164
|
+
pattern: z.string().max(1000).describe("Regex or grep pattern to search for"),
|
|
165
|
+
containers: z.array(z.string()).max(50).optional().describe("Specific containers to search (default: all running, max: 50)"),
|
|
166
|
+
tail: z.number().max(10000).optional().describe("Max lines to scan per container (default: 500, max: 10000)"),
|
|
143
167
|
since: z.string().optional().describe("Only search logs since timestamp"),
|
|
144
168
|
ignore_case: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
|
145
169
|
});
|
package/glama.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://glama.ai/schemas/glama.json",
|
|
3
|
+
"maintainers": ["friendlygeorge"],
|
|
4
|
+
"title": "Docker MCP Server",
|
|
5
|
+
"description": "31 tools for AI agent Docker management — container lifecycle, Compose stack operations, health checks, log streaming, and fleet monitoring through the Model Context Protocol.",
|
|
6
|
+
"tags": ["docker", "containers", "mcp", "compose", "health-checks", "monitoring", "devops", "ai-agents", "typescript", "self-healing"],
|
|
7
|
+
"repository": "https://github.com/friendlygeorge/docker-mcp-server",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"categories": ["virtualization", "developer-tools"],
|
|
10
|
+
"language": "TypeScript",
|
|
11
|
+
"runtime": "node"
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supernova123/docker-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "io.github.friendlygeorge/docker-mcp-server",
|
|
5
5
|
"description": "MCP server for Docker — container management, health checks, auto-restart, Compose lifecycle, and log streaming for Claude, Cursor, and AI agents",
|
|
6
6
|
"type": "module",
|
package/src/docker.ts
CHANGED
|
@@ -56,6 +56,26 @@ export function formatImage(image: Dockerode.ImageInfo): Record<string, unknown>
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
|
|
61
|
+
* Prevents prompt injection via output and caps LLM context cost.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeOutput(text: string, maxLength = 1_000_000): string {
|
|
64
|
+
// Strip ANSI escape codes (CSI, OSC, simple sequences)
|
|
65
|
+
text = text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
66
|
+
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
67
|
+
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
68
|
+
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
69
|
+
text = text.replace(/[\u{E0000}-\u{E007F}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu, "");
|
|
70
|
+
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
71
|
+
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
72
|
+
// Truncate to cap memory and LLM context cost
|
|
73
|
+
if (text.length > maxLength) {
|
|
74
|
+
return text.slice(0, maxLength) + `\n... [output truncated at ${maxLength} chars]`;
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
export function formatBytes(bytes: number): string {
|
|
60
80
|
if (bytes === 0) return "0 B";
|
|
61
81
|
const k = 1024;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
|
|
3
|
+
export interface DockerClientOptions {
|
|
4
|
+
socketPath?: string;
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createDockerClient(options?: DockerClientOptions): Dockerode {
|
|
10
|
+
if (options?.socketPath) {
|
|
11
|
+
return new Dockerode({ socketPath: options.socketPath });
|
|
12
|
+
}
|
|
13
|
+
if (options?.host && options?.port) {
|
|
14
|
+
return new Dockerode({ host: options.host, port: options.port });
|
|
15
|
+
}
|
|
16
|
+
// Default: local socket
|
|
17
|
+
return new Dockerode({ socketPath: "/var/run/docker.sock" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatError(error: unknown): string {
|
|
21
|
+
if (error instanceof Error) return error.message;
|
|
22
|
+
if (typeof error === "string") return error;
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatContainer(container: Dockerode.ContainerInfo): Record<string, unknown> {
|
|
27
|
+
return {
|
|
28
|
+
id: container.Id.substring(0, 12),
|
|
29
|
+
name: container.Names[0]?.replace(/^\//, ""),
|
|
30
|
+
image: container.Image,
|
|
31
|
+
state: container.State,
|
|
32
|
+
status: container.Status,
|
|
33
|
+
created: new Date(container.Created * 1000).toISOString(),
|
|
34
|
+
ports: container.Ports.map((p) => ({
|
|
35
|
+
private: p.PrivatePort,
|
|
36
|
+
public: p.PublicPort,
|
|
37
|
+
type: p.Type,
|
|
38
|
+
})),
|
|
39
|
+
labels: container.Labels,
|
|
40
|
+
mounts: container.Mounts.map((m) => ({
|
|
41
|
+
type: m.Type,
|
|
42
|
+
source: m.Source,
|
|
43
|
+
destination: m.Destination,
|
|
44
|
+
mode: m.Mode,
|
|
45
|
+
rw: m.RW,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatImage(image: Dockerode.ImageInfo): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
id: image.Id.substring(0, 19),
|
|
53
|
+
tags: image.RepoTags || ["<none>:<none>"],
|
|
54
|
+
size: image.Size,
|
|
55
|
+
created: new Date(image.Created).toISOString(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
|
|
61
|
+
* Prevents prompt injection via output and caps LLM context cost.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeOutput(text: string, maxLength = 1_000_000): string {
|
|
64
|
+
// Strip ANSI escape codes (CSI, OSC, simple sequences)
|
|
65
|
+
text = text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
66
|
+
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
67
|
+
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
68
|
+
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
69
|
+
text = text.replace(/[\uE0001-\uE007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, "");
|
|
70
|
+
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
71
|
+
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
72
|
+
// Truncate to cap memory and LLM context cost
|
|
73
|
+
if (text.length > maxLength) {
|
|
74
|
+
return text.slice(0, maxLength) + `\n... [output truncated at ${maxLength} chars]`;
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatBytes(bytes: number): string {
|
|
80
|
+
if (bytes === 0) return "0 B";
|
|
81
|
+
const k = 1024;
|
|
82
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
83
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
84
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
85
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -7,12 +7,13 @@ import { registerHealthTools } from "./tools/health.js";
|
|
|
7
7
|
import { registerLogsTools } from "./tools/logs.js";
|
|
8
8
|
import { registerExecTools } from "./tools/exec.js";
|
|
9
9
|
import { registerNetworkTools } from "./tools/network.js";
|
|
10
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
10
11
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
11
12
|
|
|
12
13
|
export function createServer(docker: Dockerode): McpServer {
|
|
13
14
|
const server = new McpServer({
|
|
14
15
|
name: "docker-mcp-server",
|
|
15
|
-
version: "0.
|
|
16
|
+
version: "0.3.0",
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
// Register all tool categories
|
|
@@ -23,6 +24,7 @@ export function createServer(docker: Dockerode): McpServer {
|
|
|
23
24
|
registerLogsTools(server, docker);
|
|
24
25
|
registerExecTools(server, docker);
|
|
25
26
|
registerNetworkTools(server, docker);
|
|
27
|
+
registerVolumeTools(server, docker);
|
|
26
28
|
registerMonitoringTools(server, docker);
|
|
27
29
|
|
|
28
30
|
return server;
|