@supernova123/docker-mcp-server 0.3.1 → 0.3.3
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/CHANGELOG.md +98 -0
- package/README.md +24 -0
- package/dist/docker.d.ts +10 -0
- package/dist/docker.js +79 -0
- package/dist/server.js +3 -1
- package/dist/tools/container.js +6 -6
- package/dist/tools/image.js +3 -3
- package/dist/tools/logs.js +3 -3
- package/dist/tools/network.js +3 -3
- package/dist/tools/system.d.ts +4 -0
- package/dist/tools/system.js +97 -0
- package/dist/tools/volume.js +3 -3
- package/dist/types.d.ts +4 -2
- package/dist/types.js +3 -0
- package/package.json +1 -1
- package/src/docker.ts +83 -1
- package/src/server.ts +3 -1
- package/src/tools/container.ts +14 -11
- package/src/tools/health.ts +1 -1
- package/src/tools/image.ts +3 -3
- package/src/tools/logs.ts +3 -3
- package/src/tools/network.ts +3 -3
- package/src/tools/system.ts +169 -0
- package/src/tools/volume.ts +4 -4
- package/src/types.ts +6 -1
- package/tests/retry.test.ts +82 -0
- package/tests/system.test.ts +197 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to @supernova123/docker-mcp-server will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.3.2] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Retry with exponential backoff for transient Docker API errors (`withRetry` wrapper)
|
|
9
|
+
- `isRetryableError` classifier for Docker API error codes
|
|
10
|
+
- 10 new retry/backoff unit tests
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Transient Docker API errors (ECONNRESET, ETIMEDOUT) now retry automatically
|
|
14
|
+
|
|
15
|
+
## [0.3.1] - 2026-06-13
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Startup health check (`checkDockerConnection`) — validates Docker daemon before server start
|
|
19
|
+
- Configurable timeout wrapper (`withTimeout`) — prevents indefinite hangs on slow API calls (default 30s)
|
|
20
|
+
- Structured error classes: `DockerConnectionError`, `DockerTimeoutError`, `DockerPermissionError`
|
|
21
|
+
- Enhanced `formatError()` recognizing structured error types
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Unicode regex in `sanitizeOutput` corrupting log output (#6287)
|
|
25
|
+
|
|
26
|
+
## [0.3.0] - 2026-06-13
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Volume management tools: `list_volumes`, `create_volume`, `remove_volume`, `inspect_volume`, `prune_volumes`
|
|
30
|
+
- 4 new volume tools bringing total to 31
|
|
31
|
+
|
|
32
|
+
## [0.2.5] - 2026-06-12
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- SECURITY.md with 6 audit findings and mitigations
|
|
36
|
+
- Input validation on all tool parameters
|
|
37
|
+
- Output sanitization to prevent prompt injection
|
|
38
|
+
- Size caps on container lists and log output
|
|
39
|
+
- Timeout caps on API calls
|
|
40
|
+
|
|
41
|
+
## [0.2.4] - 2026-06-12
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- MCP annotations on all 31 tools (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
|
|
45
|
+
- Rewrote 6 monitoring tool descriptions for TDQS optimization
|
|
46
|
+
- Rewrote 3 C-grade tool descriptions (`compose_logs`, `restart_container`, `stream_logs`)
|
|
47
|
+
|
|
48
|
+
## [0.2.3] - 2026-06-12
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- TDQS optimization — tool description quality improvements
|
|
52
|
+
|
|
53
|
+
## [0.2.2] - 2026-06-12
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- Glama badges to README
|
|
57
|
+
|
|
58
|
+
## [0.2.1] - 2026-06-12
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- Renamed monitoring tools for better Glama Quality score
|
|
62
|
+
|
|
63
|
+
## [0.2.0] - 2026-06-12
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
- Fleet monitoring tools: `fleet_status`, `fleet_stats`, `monitor_dashboard`, `watch_events`, `resource_alert_check`, `search_logs`
|
|
67
|
+
- 6 monitoring tools with real Docker API calls
|
|
68
|
+
- 21 unit tests for monitoring functionality
|
|
69
|
+
- Fleet Monitoring section in README
|
|
70
|
+
|
|
71
|
+
## [0.1.6] - 2026-06-11
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
- Auto-pull missing images in `run_container`
|
|
75
|
+
- Dockerfile for Docker Hub MCP org submission
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
- Handle 304 error when stopping already-stopped containers
|
|
79
|
+
|
|
80
|
+
## [0.1.4] - 2026-06-11
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
- Resolve compose path — accept both file and directory paths
|
|
84
|
+
|
|
85
|
+
## [0.1.2] - 2026-06-11
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
- Optimized npm SEO keywords and descriptions for discoverability
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
- 20 unit tests
|
|
92
|
+
- Competitive comparison and before/after framing in README
|
|
93
|
+
- Use Cases section with concrete agent scenarios
|
|
94
|
+
|
|
95
|
+
## [0.1.0] - 2026-06-10
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
- Initial release: 25 tools across container, compose, exec, health, logs, image, and network modules
|
package/README.md
CHANGED
|
@@ -39,6 +39,22 @@ There are 11+ Docker MCP servers on npm. Most are stale, GPL-licensed, or only c
|
|
|
39
39
|
|
|
40
40
|
**Debugging sessions:** Your agent execs into a container, runs diagnostics, streams logs with timestamp filters, and captures stats — all without SSH.
|
|
41
41
|
|
|
42
|
+
## How It Works
|
|
43
|
+
|
|
44
|
+
Here's what an agent actually does with this server during a deployment:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
1. Deploy: run_container(image="myapp:v2", ports={8080:80})
|
|
48
|
+
2. Health check: check_health(container="myapp", type="http", path="/ready")
|
|
49
|
+
3. Wait: watch_health(container="myapp", timeout=30)
|
|
50
|
+
4. Monitor: fleet_status() → see all containers, health states, uptime
|
|
51
|
+
5. Watch: watch_events(window=60) → detect crashes, restarts, health changes
|
|
52
|
+
6. Debug: search_logs(pattern="ERROR", containers=["myapp"])
|
|
53
|
+
7. Rollback: recreate_container(name="myapp", image="myapp:v1") if v2 fails
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If the health check fails at step 2, your agent catches it immediately — no 3am alerts, no user complaints. If the container crashes at step 5, `set_restart_policy` ensures it comes back automatically. The agent doesn't just deploy containers — it keeps them running.
|
|
57
|
+
|
|
42
58
|
## Quick Start
|
|
43
59
|
|
|
44
60
|
One command to run:
|
|
@@ -156,6 +172,14 @@ This server has **full Docker daemon access** via the Docker socket. It is desig
|
|
|
156
172
|
|
|
157
173
|
For vulnerability reports, see [SECURITY.md](SECURITY.md).
|
|
158
174
|
|
|
175
|
+
## Built by Nova
|
|
176
|
+
|
|
177
|
+
This server was built by [Nova](https://github.com/friendlygeorge), an autonomous AI agent that runs its own infrastructure, manages its own treasury, and ships tools based on real operational experience. Nova doesn't just write Docker scripts — it runs Docker every day to deploy its own services, monitor its own containers, and keep its own infrastructure alive.
|
|
178
|
+
|
|
179
|
+
The health checks, auto-restart policies, and fleet monitoring in this server exist because Nova needed them. Every tool solves a problem Nova actually hit.
|
|
180
|
+
|
|
181
|
+
Nova's other projects: [MCP servers for 9 SaaS APIs](https://github.com/friendlygeorge), [agent-native business strategy](https://dev.to/friendlygeorge/i-analyzed-150-agent-tokens-heres-what-actually-makes-money-its-not-tokens-3ho6), and [honest distribution data](https://dev.to/friendlygeorge/i-built-10-mcp-servers-in-a-week-heres-what-nobody-tells-you-about-distribution-4k38).
|
|
182
|
+
|
|
159
183
|
## License
|
|
160
184
|
|
|
161
185
|
MIT
|
package/dist/docker.d.ts
CHANGED
|
@@ -34,6 +34,16 @@ export declare function checkDockerConnection(docker: Dockerode): Promise<{
|
|
|
34
34
|
* Returns structured error on timeout instead of hanging indefinitely.
|
|
35
35
|
*/
|
|
36
36
|
export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
|
|
37
|
+
/**
|
|
38
|
+
* Retry a Docker API call with exponential backoff.
|
|
39
|
+
* Retries on transient errors (ECONNRESET, ETIMEDOUT, 5xx).
|
|
40
|
+
* Does NOT retry on 4xx errors (bad request, not found, permission denied).
|
|
41
|
+
*/
|
|
42
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: {
|
|
43
|
+
maxRetries?: number;
|
|
44
|
+
baseDelayMs?: number;
|
|
45
|
+
label?: string;
|
|
46
|
+
}): Promise<T>;
|
|
37
47
|
export declare function formatError(error: unknown): string;
|
|
38
48
|
export declare function formatContainer(container: Dockerode.ContainerInfo): Record<string, unknown>;
|
|
39
49
|
export declare function formatImage(image: Dockerode.ImageInfo): Record<string, unknown>;
|
package/dist/docker.js
CHANGED
|
@@ -73,6 +73,85 @@ export async function withTimeout(promise, ms, label) {
|
|
|
73
73
|
new Promise((_, reject) => setTimeout(() => reject(new DockerTimeoutError(`Docker API call "${label}" timed out after ${ms}ms`)), ms)),
|
|
74
74
|
]);
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Retry a Docker API call with exponential backoff.
|
|
78
|
+
* Retries on transient errors (ECONNRESET, ETIMEDOUT, 5xx).
|
|
79
|
+
* Does NOT retry on 4xx errors (bad request, not found, permission denied).
|
|
80
|
+
*/
|
|
81
|
+
export async function withRetry(fn, options = {}) {
|
|
82
|
+
const { maxRetries = 3, baseDelayMs = 1000, label = "Docker API call" } = options;
|
|
83
|
+
let lastError;
|
|
84
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
85
|
+
try {
|
|
86
|
+
return await fn();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
lastError = error;
|
|
90
|
+
// Don't retry on non-transient errors
|
|
91
|
+
if (!isRetryableError(error)) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
// Don't retry on last attempt
|
|
95
|
+
if (attempt === maxRetries) {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
99
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
100
|
+
process.stderr.write(`[retry] ${label} failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...\n`);
|
|
101
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw lastError;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Determine if an error is transient and worth retrying.
|
|
108
|
+
* Retries on: connection resets, timeouts, 5xx status codes.
|
|
109
|
+
* Does NOT retry on: 4xx (client errors), permission denied, not found.
|
|
110
|
+
*/
|
|
111
|
+
function isRetryableError(error) {
|
|
112
|
+
if (!(error instanceof Error))
|
|
113
|
+
return false;
|
|
114
|
+
const msg = error.message.toLowerCase();
|
|
115
|
+
// Connection-level transient errors
|
|
116
|
+
if (msg.includes("econnreset") || msg.includes("econnrefused"))
|
|
117
|
+
return true;
|
|
118
|
+
if (msg.includes("etimedout") || msg.includes("socket hang up"))
|
|
119
|
+
return true;
|
|
120
|
+
if (msg.includes("epipe") || msg.includes("eai_again"))
|
|
121
|
+
return true;
|
|
122
|
+
// Docker API 5xx errors (server-side)
|
|
123
|
+
if (msg.includes("status code 5"))
|
|
124
|
+
return true;
|
|
125
|
+
if (msg.includes("internal server error"))
|
|
126
|
+
return true;
|
|
127
|
+
if (msg.includes("bad gateway"))
|
|
128
|
+
return true;
|
|
129
|
+
if (msg.includes("service unavailable"))
|
|
130
|
+
return true;
|
|
131
|
+
// Docker daemon busy (transient)
|
|
132
|
+
if (msg.includes("daemon is busy"))
|
|
133
|
+
return true;
|
|
134
|
+
if (msg.includes("too many requests"))
|
|
135
|
+
return true;
|
|
136
|
+
// 4xx errors are NOT retryable (client errors)
|
|
137
|
+
if (msg.includes("status code 4"))
|
|
138
|
+
return false;
|
|
139
|
+
if (msg.includes("not found"))
|
|
140
|
+
return false;
|
|
141
|
+
if (msg.includes("permission denied"))
|
|
142
|
+
return false;
|
|
143
|
+
if (msg.includes("bad request"))
|
|
144
|
+
return false;
|
|
145
|
+
// Permission/connection errors are NOT retryable
|
|
146
|
+
if (error.name === "DockerConnectionError")
|
|
147
|
+
return false;
|
|
148
|
+
if (error.name === "DockerPermissionError")
|
|
149
|
+
return false;
|
|
150
|
+
// Timeout errors ARE retryable (might be transient)
|
|
151
|
+
if (error.name === "DockerTimeoutError")
|
|
152
|
+
return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
76
155
|
export function formatError(error) {
|
|
77
156
|
if (error instanceof DockerConnectionError)
|
|
78
157
|
return `${error.name}: ${error.message}`;
|
package/dist/server.js
CHANGED
|
@@ -8,10 +8,11 @@ import { registerExecTools } from "./tools/exec.js";
|
|
|
8
8
|
import { registerNetworkTools } from "./tools/network.js";
|
|
9
9
|
import { registerVolumeTools } from "./tools/volume.js";
|
|
10
10
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
11
|
+
import { registerSystemTools } from "./tools/system.js";
|
|
11
12
|
export function createServer(docker, options) {
|
|
12
13
|
const server = new McpServer({
|
|
13
14
|
name: "docker-mcp-server",
|
|
14
|
-
version: "0.3.
|
|
15
|
+
version: "0.3.3",
|
|
15
16
|
});
|
|
16
17
|
// Register all tool categories
|
|
17
18
|
registerContainerTools(server, docker);
|
|
@@ -23,6 +24,7 @@ export function createServer(docker, options) {
|
|
|
23
24
|
registerNetworkTools(server, docker);
|
|
24
25
|
registerVolumeTools(server, docker);
|
|
25
26
|
registerMonitoringTools(server, docker);
|
|
27
|
+
registerSystemTools(server, docker);
|
|
26
28
|
return server;
|
|
27
29
|
}
|
|
28
30
|
//# sourceMappingURL=server.js.map
|
package/dist/tools/container.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { ListContainersSchema, InspectContainerSchema, StartContainerSchema, StopContainerSchema, RestartContainerSchema, RemoveContainerSchema, RecreateContainerSchema, RunContainerSchema, } from "../types.js";
|
|
2
|
-
import { formatContainer, formatError } from "../docker.js";
|
|
2
|
+
import { formatContainer, formatError, withRetry } from "../docker.js";
|
|
3
3
|
export function registerContainerTools(server, docker) {
|
|
4
4
|
server.tool("list_containers", "List Docker containers with optional filters (state, label, name). Returns container IDs, names, images, states, ports, and labels.", ListContainersSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
5
|
try {
|
|
6
|
-
const containers = await docker.listContainers({
|
|
6
|
+
const containers = await withRetry(() => docker.listContainers({
|
|
7
7
|
all: params.all ?? false,
|
|
8
8
|
filters: JSON.stringify({
|
|
9
9
|
...(params.label ? { label: params.label } : {}),
|
|
10
10
|
...(params.name ? { name: [`/${params.name}`] } : {}),
|
|
11
11
|
...(params.state ? { status: [params.state] } : {}),
|
|
12
12
|
}),
|
|
13
|
-
});
|
|
13
|
+
}), { label: "list_containers" });
|
|
14
14
|
const results = containers.map(formatContainer);
|
|
15
15
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
16
16
|
}
|
|
@@ -31,7 +31,7 @@ export function registerContainerTools(server, docker) {
|
|
|
31
31
|
server.tool("start_container", "Start a stopped Docker container by ID or name.", StartContainerSchema.shape, { idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
32
32
|
try {
|
|
33
33
|
const container = docker.getContainer(params.container_id);
|
|
34
|
-
await container.start();
|
|
34
|
+
await withRetry(() => container.start(), { label: "start_container" });
|
|
35
35
|
return { content: [{ type: "text", text: `Container ${params.container_id} started.` }] };
|
|
36
36
|
}
|
|
37
37
|
catch (error) {
|
|
@@ -41,7 +41,7 @@ export function registerContainerTools(server, docker) {
|
|
|
41
41
|
server.tool("stop_container", "Stop a running Docker container by ID or name with optional timeout.", StopContainerSchema.shape, { destructiveHint: true, openWorldHint: false }, async (params) => {
|
|
42
42
|
try {
|
|
43
43
|
const container = docker.getContainer(params.container_id);
|
|
44
|
-
await container.stop({ t: params.timeout ?? 10 });
|
|
44
|
+
await withRetry(() => container.stop({ t: params.timeout ?? 10 }), { label: "stop_container" });
|
|
45
45
|
return { content: [{ type: "text", text: `Container ${params.container_id} stopped.` }] };
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
|
@@ -55,7 +55,7 @@ export function registerContainerTools(server, docker) {
|
|
|
55
55
|
server.tool("restart_container", "Restart a Docker container by ID or name with optional timeout. This tears down the running process and starts a new one — use stop_container for a graceful shutdown or remove_container to delete entirely. The timeout parameter (default 10s) controls how long to wait before force-killing. Returns a confirmation string on success. Idempotent: restarting an already-stopped container starts it again. Returns an error string if the container does not exist or is not running.", RestartContainerSchema.shape, { destructiveHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
56
56
|
try {
|
|
57
57
|
const container = docker.getContainer(params.container_id);
|
|
58
|
-
await container.restart({ t: params.timeout ?? 10 });
|
|
58
|
+
await withRetry(() => container.restart({ t: params.timeout ?? 10 }), { label: "restart_container" });
|
|
59
59
|
return { content: [{ type: "text", text: `Container ${params.container_id} restarted.` }] };
|
|
60
60
|
}
|
|
61
61
|
catch (error) {
|
package/dist/tools/image.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { ListImagesSchema, PullImageSchema, BuildImageSchema, RemoveImageSchema, } from "../types.js";
|
|
2
|
-
import { formatImage, formatError } from "../docker.js";
|
|
2
|
+
import { formatImage, formatError, withRetry } from "../docker.js";
|
|
3
3
|
export function registerImageTools(server, docker) {
|
|
4
4
|
server.tool("list_images", "List Docker images with optional filters. Returns image IDs, tags, sizes, and creation dates.", ListImagesSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
5
|
try {
|
|
6
|
-
const images = await docker.listImages({
|
|
6
|
+
const images = await withRetry(() => docker.listImages({
|
|
7
7
|
all: params.all ?? false,
|
|
8
8
|
filters: params.filter ? JSON.stringify({ reference: [params.filter] }) : undefined,
|
|
9
|
-
});
|
|
9
|
+
}), { label: "list_images" });
|
|
10
10
|
const results = images.map(formatImage);
|
|
11
11
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
12
12
|
}
|
package/dist/tools/logs.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
|
|
2
|
-
import { formatError,
|
|
2
|
+
import { formatError, sanitizeOutput, withRetry, formatBytes } 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 {
|
|
6
6
|
const container = docker.getContainer(params.container_id);
|
|
7
|
-
const logs = await container.logs({
|
|
7
|
+
const logs = await withRetry(() => container.logs({
|
|
8
8
|
stdout: true,
|
|
9
9
|
stderr: true,
|
|
10
10
|
tail: params.tail ?? 100,
|
|
11
11
|
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
12
12
|
follow: false,
|
|
13
|
-
});
|
|
13
|
+
}), { label: "container_logs" });
|
|
14
14
|
// Dockerode returns a Buffer with multiplexed stream headers
|
|
15
15
|
// Use 100KB cap for logs to keep LLM context small
|
|
16
16
|
const output = sanitizeOutput(logs.toString("utf-8"), 100_000);
|
package/dist/tools/network.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { ListNetworksSchema, ListVolumesSchema } from "../types.js";
|
|
2
|
-
import { formatError } from "../docker.js";
|
|
2
|
+
import { formatError, withRetry } from "../docker.js";
|
|
3
3
|
export function registerNetworkTools(server, docker) {
|
|
4
4
|
server.tool("list_networks", "List Docker networks with optional filter. Returns network IDs, names, drivers, and scopes.", ListNetworksSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
5
|
try {
|
|
6
|
-
const networks = await docker.listNetworks({
|
|
6
|
+
const networks = await withRetry(() => docker.listNetworks({
|
|
7
7
|
filters: params.filter ? JSON.stringify({ name: [params.filter] }) : undefined,
|
|
8
|
-
});
|
|
8
|
+
}), { label: "list_networks" });
|
|
9
9
|
const results = networks.map((n) => ({
|
|
10
10
|
id: n.Id.substring(0, 12),
|
|
11
11
|
name: n.Name,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { DockerInfoSchema, DiskUsageSchema } from "../types.js";
|
|
2
|
+
import { formatError, withRetry } from "../docker.js";
|
|
3
|
+
export function registerSystemTools(server, docker) {
|
|
4
|
+
server.tool("docker_info", "Get Docker daemon system information: server version, OS, kernel, CPU count, memory total, storage driver, and running container/image counts.", DockerInfoSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
|
+
try {
|
|
6
|
+
const info = (await withRetry(() => docker.info(), { label: "docker_info" }));
|
|
7
|
+
return {
|
|
8
|
+
content: [{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: JSON.stringify({
|
|
11
|
+
server_version: info.ServerVersion,
|
|
12
|
+
os: info.OperatingSystem,
|
|
13
|
+
kernel: info.KernelVersion,
|
|
14
|
+
architecture: info.Architecture,
|
|
15
|
+
cpus: info.NCPU,
|
|
16
|
+
memory_total: info.MemTotal,
|
|
17
|
+
memory_total_human: formatBytes(info.MemTotal),
|
|
18
|
+
docker_root: info.DockerRootDir,
|
|
19
|
+
storage_driver: info.Driver,
|
|
20
|
+
containers_running: info.ContainersRunning,
|
|
21
|
+
containers_stopped: info.ContainersStopped,
|
|
22
|
+
containers_paused: info.ContainersPaused,
|
|
23
|
+
images: info.Images,
|
|
24
|
+
labels: info.Labels,
|
|
25
|
+
server_id: info.ID,
|
|
26
|
+
}, null, 2),
|
|
27
|
+
}],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
server.tool("disk_usage", "Get Docker disk usage breakdown: space used by images, containers, volumes, and build cache. Shows total and reclaimable space.", DiskUsageSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
35
|
+
try {
|
|
36
|
+
const df = (await withRetry(() => docker.df(), { label: "disk_usage" }));
|
|
37
|
+
const images = (df.Images || []).map((img) => ({
|
|
38
|
+
id: img.Id?.substring(0, 19),
|
|
39
|
+
tags: img.RepoTags || [],
|
|
40
|
+
size: img.Size,
|
|
41
|
+
size_human: formatBytes(img.Size),
|
|
42
|
+
containers: img.Containers,
|
|
43
|
+
}));
|
|
44
|
+
const containers = (df.Containers || []).map((c) => ({
|
|
45
|
+
id: c.Id?.substring(0, 12),
|
|
46
|
+
name: c.Name,
|
|
47
|
+
image: c.Image,
|
|
48
|
+
size: c.Size,
|
|
49
|
+
size_human: formatBytes(c.Size),
|
|
50
|
+
reclaimable: c.Reclaimable,
|
|
51
|
+
}));
|
|
52
|
+
const volumes = (df.Volumes || []).map((v) => ({
|
|
53
|
+
name: v.Name,
|
|
54
|
+
size: v.Size,
|
|
55
|
+
size_human: formatBytes(v.Size),
|
|
56
|
+
reclaimable: v.Reclaimable,
|
|
57
|
+
}));
|
|
58
|
+
const buildCache = (df.BuildCache || []).map((bc) => ({
|
|
59
|
+
id: bc.ID,
|
|
60
|
+
type: bc.Type,
|
|
61
|
+
description: bc.Description?.substring(0, 120),
|
|
62
|
+
size: bc.Size,
|
|
63
|
+
size_human: formatBytes(bc.Size),
|
|
64
|
+
in_use: bc.InUse,
|
|
65
|
+
}));
|
|
66
|
+
return {
|
|
67
|
+
content: [{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: JSON.stringify({
|
|
70
|
+
summary: {
|
|
71
|
+
images: { count: df.LayersSize ? images.length : 0, total_size: df.LayersSize, total_human: formatBytes(df.LayersSize || 0) },
|
|
72
|
+
containers: { count: containers.length },
|
|
73
|
+
volumes: { count: volumes.length },
|
|
74
|
+
build_cache: { count: buildCache.length, total_human: formatBytes(buildCache.reduce((sum, bc) => sum + (bc.size || 0), 0)) },
|
|
75
|
+
},
|
|
76
|
+
images,
|
|
77
|
+
containers,
|
|
78
|
+
volumes,
|
|
79
|
+
build_cache: buildCache.slice(0, 10), // Top 10 only
|
|
80
|
+
}, null, 2),
|
|
81
|
+
}],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function formatBytes(bytes) {
|
|
90
|
+
if (bytes === 0)
|
|
91
|
+
return "0 B";
|
|
92
|
+
const k = 1024;
|
|
93
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
94
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
95
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=system.js.map
|
package/dist/tools/volume.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { CreateVolumeSchema, InspectVolumeSchema, RemoveVolumeSchema, PruneVolumesSchema, } from "../types.js";
|
|
2
|
-
import { formatError } from "../docker.js";
|
|
2
|
+
import { formatError, withRetry } from "../docker.js";
|
|
3
3
|
export function registerVolumeTools(server, docker) {
|
|
4
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
5
|
try {
|
|
6
|
-
const result = await docker.createVolume({
|
|
6
|
+
const result = await withRetry(() => docker.createVolume({
|
|
7
7
|
Name: params.name,
|
|
8
8
|
Driver: params.driver || "local",
|
|
9
9
|
Labels: params.labels,
|
|
10
10
|
DriverOpts: params.options,
|
|
11
|
-
});
|
|
11
|
+
}), { label: "create_volume" });
|
|
12
12
|
return {
|
|
13
13
|
content: [{
|
|
14
14
|
type: "text",
|
package/dist/types.d.ts
CHANGED
|
@@ -5,15 +5,15 @@ export declare const ListContainersSchema: z.ZodObject<{
|
|
|
5
5
|
name: z.ZodOptional<z.ZodString>;
|
|
6
6
|
state: z.ZodOptional<z.ZodEnum<["running", "stopped", "paused", "exited", "created", "restarting"]>>;
|
|
7
7
|
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
label?: string[] | undefined;
|
|
8
9
|
name?: string | undefined;
|
|
9
10
|
state?: "created" | "running" | "stopped" | "paused" | "exited" | "restarting" | undefined;
|
|
10
11
|
all?: boolean | undefined;
|
|
11
|
-
label?: string[] | undefined;
|
|
12
12
|
}, {
|
|
13
|
+
label?: string[] | undefined;
|
|
13
14
|
name?: string | undefined;
|
|
14
15
|
state?: "created" | "running" | "stopped" | "paused" | "exited" | "restarting" | undefined;
|
|
15
16
|
all?: boolean | undefined;
|
|
16
|
-
label?: string[] | undefined;
|
|
17
17
|
}>;
|
|
18
18
|
export declare const InspectContainerSchema: z.ZodObject<{
|
|
19
19
|
container_id: z.ZodString;
|
|
@@ -403,4 +403,6 @@ export declare const ResourceAlertCheckSchema: z.ZodObject<{
|
|
|
403
403
|
restart_count?: number | undefined;
|
|
404
404
|
}>;
|
|
405
405
|
export declare const MonitorDashboardSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
406
|
+
export declare const DockerInfoSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
407
|
+
export declare const DiskUsageSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
406
408
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
CHANGED
|
@@ -173,4 +173,7 @@ export const ResourceAlertCheckSchema = z.object({
|
|
|
173
173
|
restart_count: z.number().optional().describe("Alert if restart count exceeds this (default: 5)"),
|
|
174
174
|
});
|
|
175
175
|
export const MonitorDashboardSchema = z.object({});
|
|
176
|
+
// System info schemas (v0.3.3)
|
|
177
|
+
export const DockerInfoSchema = z.object({});
|
|
178
|
+
export const DiskUsageSchema = z.object({});
|
|
176
179
|
//# sourceMappingURL=types.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supernova123/docker-mcp-server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
@@ -96,6 +96,88 @@ export async function withTimeout<T>(
|
|
|
96
96
|
]);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Retry a Docker API call with exponential backoff.
|
|
102
|
+
* Retries on transient errors (ECONNRESET, ETIMEDOUT, 5xx).
|
|
103
|
+
* Does NOT retry on 4xx errors (bad request, not found, permission denied).
|
|
104
|
+
*/
|
|
105
|
+
export async function withRetry<T>(
|
|
106
|
+
fn: () => Promise<T>,
|
|
107
|
+
options: { maxRetries?: number; baseDelayMs?: number; label?: string } = {}
|
|
108
|
+
): Promise<T> {
|
|
109
|
+
const { maxRetries = 3, baseDelayMs = 1000, label = "Docker API call" } = options;
|
|
110
|
+
let lastError: unknown;
|
|
111
|
+
|
|
112
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
113
|
+
try {
|
|
114
|
+
return await fn();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
lastError = error;
|
|
117
|
+
|
|
118
|
+
// Don't retry on non-transient errors
|
|
119
|
+
if (!isRetryableError(error)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Don't retry on last attempt
|
|
124
|
+
if (attempt === maxRetries) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
129
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`[retry] ${label} failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...\n`
|
|
132
|
+
);
|
|
133
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
throw lastError;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Determine if an error is transient and worth retrying.
|
|
142
|
+
* Retries on: connection resets, timeouts, 5xx status codes.
|
|
143
|
+
* Does NOT retry on: 4xx (client errors), permission denied, not found.
|
|
144
|
+
*/
|
|
145
|
+
function isRetryableError(error: unknown): boolean {
|
|
146
|
+
if (!(error instanceof Error)) return false;
|
|
147
|
+
|
|
148
|
+
const msg = error.message.toLowerCase();
|
|
149
|
+
|
|
150
|
+
// Connection-level transient errors
|
|
151
|
+
if (msg.includes("econnreset") || msg.includes("econnrefused")) return true;
|
|
152
|
+
if (msg.includes("etimedout") || msg.includes("socket hang up")) return true;
|
|
153
|
+
if (msg.includes("epipe") || msg.includes("eai_again")) return true;
|
|
154
|
+
|
|
155
|
+
// Docker API 5xx errors (server-side)
|
|
156
|
+
if (msg.includes("status code 5")) return true;
|
|
157
|
+
if (msg.includes("internal server error")) return true;
|
|
158
|
+
if (msg.includes("bad gateway")) return true;
|
|
159
|
+
if (msg.includes("service unavailable")) return true;
|
|
160
|
+
|
|
161
|
+
// Docker daemon busy (transient)
|
|
162
|
+
if (msg.includes("daemon is busy")) return true;
|
|
163
|
+
if (msg.includes("too many requests")) return true;
|
|
164
|
+
|
|
165
|
+
// 4xx errors are NOT retryable (client errors)
|
|
166
|
+
if (msg.includes("status code 4")) return false;
|
|
167
|
+
if (msg.includes("not found")) return false;
|
|
168
|
+
if (msg.includes("permission denied")) return false;
|
|
169
|
+
if (msg.includes("bad request")) return false;
|
|
170
|
+
|
|
171
|
+
// Permission/connection errors are NOT retryable
|
|
172
|
+
if (error.name === "DockerConnectionError") return false;
|
|
173
|
+
if (error.name === "DockerPermissionError") return false;
|
|
174
|
+
|
|
175
|
+
// Timeout errors ARE retryable (might be transient)
|
|
176
|
+
if (error.name === "DockerTimeoutError") return true;
|
|
177
|
+
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
99
181
|
export function formatError(error: unknown): string {
|
|
100
182
|
if (error instanceof DockerConnectionError) return `${error.name}: ${error.message}`;
|
|
101
183
|
if (error instanceof DockerTimeoutError) return `${error.name}: ${error.message}`;
|
|
@@ -164,4 +246,4 @@ export function formatBytes(bytes: number): string {
|
|
|
164
246
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
165
247
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
166
248
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
167
|
-
}
|
|
249
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { registerExecTools } from "./tools/exec.js";
|
|
|
9
9
|
import { registerNetworkTools } from "./tools/network.js";
|
|
10
10
|
import { registerVolumeTools } from "./tools/volume.js";
|
|
11
11
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
12
|
+
import { registerSystemTools } from "./tools/system.js";
|
|
12
13
|
|
|
13
14
|
export interface ServerOptions {
|
|
14
15
|
timeoutMs?: number;
|
|
@@ -17,7 +18,7 @@ export interface ServerOptions {
|
|
|
17
18
|
export function createServer(docker: Dockerode, options?: ServerOptions): McpServer {
|
|
18
19
|
const server = new McpServer({
|
|
19
20
|
name: "docker-mcp-server",
|
|
20
|
-
version: "0.3.
|
|
21
|
+
version: "0.3.3",
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
// Register all tool categories
|
|
@@ -30,6 +31,7 @@ export function createServer(docker: Dockerode, options?: ServerOptions): McpSer
|
|
|
30
31
|
registerNetworkTools(server, docker);
|
|
31
32
|
registerVolumeTools(server, docker);
|
|
32
33
|
registerMonitoringTools(server, docker);
|
|
34
|
+
registerSystemTools(server, docker);
|
|
33
35
|
|
|
34
36
|
return server;
|
|
35
37
|
}
|
package/src/tools/container.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
RecreateContainerSchema,
|
|
11
11
|
RunContainerSchema,
|
|
12
12
|
} from "../types.js";
|
|
13
|
-
import { formatContainer, formatError } from "../docker.js";
|
|
13
|
+
import { formatContainer, formatError, withRetry } from "../docker.js";
|
|
14
14
|
|
|
15
15
|
export function registerContainerTools(server: McpServer, docker: Dockerode): void {
|
|
16
16
|
server.tool(
|
|
@@ -20,14 +20,17 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
20
20
|
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
21
21
|
async (params) => {
|
|
22
22
|
try {
|
|
23
|
-
const containers = await
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const containers = await withRetry(
|
|
24
|
+
() => docker.listContainers({
|
|
25
|
+
all: params.all ?? false,
|
|
26
|
+
filters: JSON.stringify({
|
|
27
|
+
...(params.label ? { label: params.label } : {}),
|
|
28
|
+
...(params.name ? { name: [`/${params.name}`] } : {}),
|
|
29
|
+
...(params.state ? { status: [params.state] } : {}),
|
|
30
|
+
}),
|
|
29
31
|
}),
|
|
30
|
-
|
|
32
|
+
{ label: "list_containers" }
|
|
33
|
+
);
|
|
31
34
|
const results = containers.map(formatContainer);
|
|
32
35
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
33
36
|
} catch (error) {
|
|
@@ -60,7 +63,7 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
60
63
|
async (params) => {
|
|
61
64
|
try {
|
|
62
65
|
const container = docker.getContainer(params.container_id);
|
|
63
|
-
await container.start();
|
|
66
|
+
await withRetry(() => container.start(), { label: "start_container" });
|
|
64
67
|
return { content: [{ type: "text", text: `Container ${params.container_id} started.` }] };
|
|
65
68
|
} catch (error) {
|
|
66
69
|
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
@@ -76,7 +79,7 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
76
79
|
async (params) => {
|
|
77
80
|
try {
|
|
78
81
|
const container = docker.getContainer(params.container_id);
|
|
79
|
-
await container.stop({ t: params.timeout ?? 10 });
|
|
82
|
+
await withRetry(() => container.stop({ t: params.timeout ?? 10 }), { label: "stop_container" });
|
|
80
83
|
return { content: [{ type: "text", text: `Container ${params.container_id} stopped.` }] };
|
|
81
84
|
} catch (error: any) {
|
|
82
85
|
// 304 means container is already stopped — treat as success
|
|
@@ -96,7 +99,7 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
96
99
|
async (params) => {
|
|
97
100
|
try {
|
|
98
101
|
const container = docker.getContainer(params.container_id);
|
|
99
|
-
await container.restart({ t: params.timeout ?? 10 });
|
|
102
|
+
await withRetry(() => container.restart({ t: params.timeout ?? 10 }), { label: "restart_container" });
|
|
100
103
|
return { content: [{ type: "text", text: `Container ${params.container_id} restarted.` }] };
|
|
101
104
|
} catch (error) {
|
|
102
105
|
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
package/src/tools/health.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Dockerode from "dockerode";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { CheckHealthSchema, WatchHealthSchema, SetRestartPolicySchema } from "../types.js";
|
|
4
|
-
import { formatError } from "../docker.js";
|
|
4
|
+
import { formatError, withRetry } from "../docker.js";
|
|
5
5
|
|
|
6
6
|
export function registerHealthTools(server: McpServer, docker: Dockerode): void {
|
|
7
7
|
server.tool(
|
package/src/tools/image.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
BuildImageSchema,
|
|
7
7
|
RemoveImageSchema,
|
|
8
8
|
} from "../types.js";
|
|
9
|
-
import { formatImage, formatError } from "../docker.js";
|
|
9
|
+
import { formatImage, formatError, withRetry } from "../docker.js";
|
|
10
10
|
|
|
11
11
|
export function registerImageTools(server: McpServer, docker: Dockerode): void {
|
|
12
12
|
server.tool(
|
|
@@ -16,10 +16,10 @@ export function registerImageTools(server: McpServer, docker: Dockerode): void {
|
|
|
16
16
|
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
17
17
|
async (params) => {
|
|
18
18
|
try {
|
|
19
|
-
const images = await docker.listImages({
|
|
19
|
+
const images = await withRetry(() => docker.listImages({
|
|
20
20
|
all: params.all ?? false,
|
|
21
21
|
filters: params.filter ? JSON.stringify({ reference: [params.filter] }) : undefined,
|
|
22
|
-
});
|
|
22
|
+
}), { label: "list_images" });
|
|
23
23
|
const results = images.map(formatImage);
|
|
24
24
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
25
25
|
} catch (error) {
|
package/src/tools/logs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Dockerode from "dockerode";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
|
|
4
|
-
import { formatError,
|
|
4
|
+
import { formatError, sanitizeOutput, withRetry, formatBytes } from "../docker.js";
|
|
5
5
|
|
|
6
6
|
export function registerLogsTools(server: McpServer, docker: Dockerode): void {
|
|
7
7
|
server.tool(
|
|
@@ -12,13 +12,13 @@ export function registerLogsTools(server: McpServer, docker: Dockerode): void {
|
|
|
12
12
|
async (params) => {
|
|
13
13
|
try {
|
|
14
14
|
const container = docker.getContainer(params.container_id);
|
|
15
|
-
const logs = await container.logs({
|
|
15
|
+
const logs = await withRetry(() => container.logs({
|
|
16
16
|
stdout: true,
|
|
17
17
|
stderr: true,
|
|
18
18
|
tail: params.tail ?? 100,
|
|
19
19
|
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
20
20
|
follow: false as const,
|
|
21
|
-
});
|
|
21
|
+
}), { label: "container_logs" });
|
|
22
22
|
// Dockerode returns a Buffer with multiplexed stream headers
|
|
23
23
|
// Use 100KB cap for logs to keep LLM context small
|
|
24
24
|
const output = sanitizeOutput(logs.toString("utf-8"), 100_000);
|
package/src/tools/network.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Dockerode from "dockerode";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { ListNetworksSchema, ListVolumesSchema } from "../types.js";
|
|
4
|
-
import { formatError } from "../docker.js";
|
|
4
|
+
import { formatError, withRetry } from "../docker.js";
|
|
5
5
|
|
|
6
6
|
export function registerNetworkTools(server: McpServer, docker: Dockerode): void {
|
|
7
7
|
server.tool(
|
|
@@ -11,9 +11,9 @@ export function registerNetworkTools(server: McpServer, docker: Dockerode): void
|
|
|
11
11
|
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
12
12
|
async (params) => {
|
|
13
13
|
try {
|
|
14
|
-
const networks = await docker.listNetworks({
|
|
14
|
+
const networks = await withRetry(() => docker.listNetworks({
|
|
15
15
|
filters: params.filter ? JSON.stringify({ name: [params.filter] }) : undefined,
|
|
16
|
-
});
|
|
16
|
+
}), { label: "list_networks" });
|
|
17
17
|
const results = networks.map((n) => ({
|
|
18
18
|
id: n.Id.substring(0, 12),
|
|
19
19
|
name: n.Name,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { DockerInfoSchema, DiskUsageSchema } from "../types.js";
|
|
4
|
+
import { formatError, withRetry } from "../docker.js";
|
|
5
|
+
|
|
6
|
+
interface DockerInfoResult {
|
|
7
|
+
ServerVersion: string;
|
|
8
|
+
OperatingSystem: string;
|
|
9
|
+
KernelVersion: string;
|
|
10
|
+
Architecture: string;
|
|
11
|
+
NCPU: number;
|
|
12
|
+
MemTotal: number;
|
|
13
|
+
DockerRootDir: string;
|
|
14
|
+
Driver: string;
|
|
15
|
+
ContainersRunning: number;
|
|
16
|
+
ContainersStopped: number;
|
|
17
|
+
ContainersPaused: number;
|
|
18
|
+
Images: number;
|
|
19
|
+
Labels: string[];
|
|
20
|
+
ID: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface DiskUsageImage {
|
|
24
|
+
Id: string;
|
|
25
|
+
RepoTags: string[];
|
|
26
|
+
Size: number;
|
|
27
|
+
Containers: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface DiskUsageContainer {
|
|
31
|
+
Id: string;
|
|
32
|
+
Name: string;
|
|
33
|
+
Image: string;
|
|
34
|
+
Size: number;
|
|
35
|
+
Reclaimable: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DiskUsageVolume {
|
|
39
|
+
Name: string;
|
|
40
|
+
Size: number;
|
|
41
|
+
Reclaimable: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DiskUsageBuildCache {
|
|
45
|
+
ID: string;
|
|
46
|
+
Type: string;
|
|
47
|
+
Description: string;
|
|
48
|
+
Size: number;
|
|
49
|
+
InUse: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface DiskUsageResult {
|
|
53
|
+
LayersSize: number;
|
|
54
|
+
Images: DiskUsageImage[];
|
|
55
|
+
Containers: DiskUsageContainer[];
|
|
56
|
+
Volumes: DiskUsageVolume[];
|
|
57
|
+
BuildCache: DiskUsageBuildCache[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerSystemTools(server: McpServer, docker: Dockerode): void {
|
|
61
|
+
server.tool(
|
|
62
|
+
"docker_info",
|
|
63
|
+
"Get Docker daemon system information: server version, OS, kernel, CPU count, memory total, storage driver, and running container/image counts.",
|
|
64
|
+
DockerInfoSchema.shape,
|
|
65
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
66
|
+
async (params) => {
|
|
67
|
+
try {
|
|
68
|
+
const info = (await withRetry(() => docker.info(), { label: "docker_info" })) as DockerInfoResult;
|
|
69
|
+
return {
|
|
70
|
+
content: [{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: JSON.stringify({
|
|
73
|
+
server_version: info.ServerVersion,
|
|
74
|
+
os: info.OperatingSystem,
|
|
75
|
+
kernel: info.KernelVersion,
|
|
76
|
+
architecture: info.Architecture,
|
|
77
|
+
cpus: info.NCPU,
|
|
78
|
+
memory_total: info.MemTotal,
|
|
79
|
+
memory_total_human: formatBytes(info.MemTotal),
|
|
80
|
+
docker_root: info.DockerRootDir,
|
|
81
|
+
storage_driver: info.Driver,
|
|
82
|
+
containers_running: info.ContainersRunning,
|
|
83
|
+
containers_stopped: info.ContainersStopped,
|
|
84
|
+
containers_paused: info.ContainersPaused,
|
|
85
|
+
images: info.Images,
|
|
86
|
+
labels: info.Labels,
|
|
87
|
+
server_id: info.ID,
|
|
88
|
+
}, null, 2),
|
|
89
|
+
}],
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.tool(
|
|
98
|
+
"disk_usage",
|
|
99
|
+
"Get Docker disk usage breakdown: space used by images, containers, volumes, and build cache. Shows total and reclaimable space.",
|
|
100
|
+
DiskUsageSchema.shape,
|
|
101
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
102
|
+
async (params) => {
|
|
103
|
+
try {
|
|
104
|
+
const df = (await withRetry(() => docker.df(), { label: "disk_usage" })) as DiskUsageResult;
|
|
105
|
+
|
|
106
|
+
const images = (df.Images || []).map((img) => ({
|
|
107
|
+
id: img.Id?.substring(0, 19),
|
|
108
|
+
tags: img.RepoTags || [],
|
|
109
|
+
size: img.Size,
|
|
110
|
+
size_human: formatBytes(img.Size),
|
|
111
|
+
containers: img.Containers,
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const containers = (df.Containers || []).map((c) => ({
|
|
115
|
+
id: c.Id?.substring(0, 12),
|
|
116
|
+
name: c.Name,
|
|
117
|
+
image: c.Image,
|
|
118
|
+
size: c.Size,
|
|
119
|
+
size_human: formatBytes(c.Size),
|
|
120
|
+
reclaimable: c.Reclaimable,
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const volumes = (df.Volumes || []).map((v) => ({
|
|
124
|
+
name: v.Name,
|
|
125
|
+
size: v.Size,
|
|
126
|
+
size_human: formatBytes(v.Size),
|
|
127
|
+
reclaimable: v.Reclaimable,
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const buildCache = (df.BuildCache || []).map((bc) => ({
|
|
131
|
+
id: bc.ID,
|
|
132
|
+
type: bc.Type,
|
|
133
|
+
description: bc.Description?.substring(0, 120),
|
|
134
|
+
size: bc.Size,
|
|
135
|
+
size_human: formatBytes(bc.Size),
|
|
136
|
+
in_use: bc.InUse,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
content: [{
|
|
141
|
+
type: "text",
|
|
142
|
+
text: JSON.stringify({
|
|
143
|
+
summary: {
|
|
144
|
+
images: { count: df.LayersSize ? images.length : 0, total_size: df.LayersSize, total_human: formatBytes(df.LayersSize || 0) },
|
|
145
|
+
containers: { count: containers.length },
|
|
146
|
+
volumes: { count: volumes.length },
|
|
147
|
+
build_cache: { count: buildCache.length, total_human: formatBytes(buildCache.reduce((sum, bc) => sum + (bc.size || 0), 0)) },
|
|
148
|
+
},
|
|
149
|
+
images,
|
|
150
|
+
containers,
|
|
151
|
+
volumes,
|
|
152
|
+
build_cache: buildCache.slice(0, 10), // Top 10 only
|
|
153
|
+
}, null, 2),
|
|
154
|
+
}],
|
|
155
|
+
};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatBytes(bytes: number): string {
|
|
164
|
+
if (bytes === 0) return "0 B";
|
|
165
|
+
const k = 1024;
|
|
166
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
167
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
168
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
169
|
+
}
|
package/src/tools/volume.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
RemoveVolumeSchema,
|
|
7
7
|
PruneVolumesSchema,
|
|
8
8
|
} from "../types.js";
|
|
9
|
-
import { formatError } from "../docker.js";
|
|
9
|
+
import { formatError, withRetry } from "../docker.js";
|
|
10
10
|
|
|
11
11
|
export function registerVolumeTools(server: McpServer, docker: Dockerode): void {
|
|
12
12
|
server.tool(
|
|
@@ -16,12 +16,12 @@ export function registerVolumeTools(server: McpServer, docker: Dockerode): void
|
|
|
16
16
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
17
17
|
async (params) => {
|
|
18
18
|
try {
|
|
19
|
-
const result = await docker.createVolume({
|
|
19
|
+
const result = await withRetry(() => docker.createVolume({
|
|
20
20
|
Name: params.name,
|
|
21
21
|
Driver: params.driver || "local",
|
|
22
22
|
Labels: params.labels,
|
|
23
23
|
DriverOpts: params.options,
|
|
24
|
-
});
|
|
24
|
+
}), { label: "create_volume" });
|
|
25
25
|
return {
|
|
26
26
|
content: [{
|
|
27
27
|
type: "text",
|
|
@@ -122,4 +122,4 @@ export function registerVolumeTools(server: McpServer, docker: Dockerode): void
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
);
|
|
125
|
-
}
|
|
125
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -215,4 +215,9 @@ export const ResourceAlertCheckSchema = z.object({
|
|
|
215
215
|
restart_count: z.number().optional().describe("Alert if restart count exceeds this (default: 5)"),
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
export const MonitorDashboardSchema = z.object({});
|
|
218
|
+
export const MonitorDashboardSchema = z.object({});
|
|
219
|
+
|
|
220
|
+
// System info schemas (v0.3.3)
|
|
221
|
+
export const DockerInfoSchema = z.object({});
|
|
222
|
+
|
|
223
|
+
export const DiskUsageSchema = z.object({});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { withRetry, DockerTimeoutError, DockerConnectionError, DockerPermissionError } from "../src/docker.js";
|
|
3
|
+
|
|
4
|
+
describe("withRetry", () => {
|
|
5
|
+
it("returns result on first attempt (no retry needed)", async () => {
|
|
6
|
+
const fn = vi.fn().mockResolvedValue("success");
|
|
7
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
8
|
+
expect(result).toBe("success");
|
|
9
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("retries on transient ECONNRESET error", async () => {
|
|
13
|
+
const fn = vi.fn()
|
|
14
|
+
.mockRejectedValueOnce(new Error("read ECONNRESET"))
|
|
15
|
+
.mockResolvedValue("recovered");
|
|
16
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10, label: "test" });
|
|
17
|
+
expect(result).toBe("recovered");
|
|
18
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("retries on timeout error", async () => {
|
|
22
|
+
const fn = vi.fn()
|
|
23
|
+
.mockRejectedValueOnce(new DockerTimeoutError("timed out"))
|
|
24
|
+
.mockResolvedValue("recovered");
|
|
25
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
26
|
+
expect(result).toBe("recovered");
|
|
27
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does NOT retry on 404 Not Found", async () => {
|
|
31
|
+
const fn = vi.fn().mockRejectedValue(new Error("HTTP 404: Not Found"));
|
|
32
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
33
|
+
.rejects.toThrow("HTTP 404: Not Found");
|
|
34
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does NOT retry on permission denied", async () => {
|
|
38
|
+
const fn = vi.fn().mockRejectedValue(new Error("Permission denied"));
|
|
39
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
40
|
+
.rejects.toThrow("Permission denied");
|
|
41
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does NOT retry on DockerConnectionError", async () => {
|
|
45
|
+
const fn = vi.fn().mockRejectedValue(new DockerConnectionError("cannot connect"));
|
|
46
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
47
|
+
.rejects.toThrow("cannot connect");
|
|
48
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does NOT retry on DockerPermissionError", async () => {
|
|
52
|
+
const fn = vi.fn().mockRejectedValue(new DockerPermissionError("access denied"));
|
|
53
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
54
|
+
.rejects.toThrow("access denied");
|
|
55
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("retries on 500 Internal Server Error", async () => {
|
|
59
|
+
const fn = vi.fn()
|
|
60
|
+
.mockRejectedValueOnce(new Error("HTTP 500: Internal Server Error"))
|
|
61
|
+
.mockResolvedValue("recovered");
|
|
62
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
63
|
+
expect(result).toBe("recovered");
|
|
64
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("exhausts retries and throws last error", async () => {
|
|
68
|
+
const fn = vi.fn().mockRejectedValue(new Error("read ECONNRESET"));
|
|
69
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 10 }))
|
|
70
|
+
.rejects.toThrow("read ECONNRESET");
|
|
71
|
+
expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("retries on socket hang up", async () => {
|
|
75
|
+
const fn = vi.fn()
|
|
76
|
+
.mockRejectedValueOnce(new Error("socket hang up"))
|
|
77
|
+
.mockResolvedValue("ok");
|
|
78
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
79
|
+
expect(result).toBe("ok");
|
|
80
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock Dockerode before importing the module under test
|
|
4
|
+
const mockInfo = vi.fn();
|
|
5
|
+
const mockDf = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock("dockerode", () => {
|
|
8
|
+
return {
|
|
9
|
+
default: vi.fn().mockImplementation(() => ({
|
|
10
|
+
info: mockInfo,
|
|
11
|
+
df: mockDf,
|
|
12
|
+
})),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
import { registerSystemTools } from "../src/tools/system.js";
|
|
17
|
+
|
|
18
|
+
// Minimal MCP server mock
|
|
19
|
+
function createMockServer() {
|
|
20
|
+
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
21
|
+
return {
|
|
22
|
+
tool: (name: string, description: string, _schema: unknown, _hints: unknown, handler: Function) => {
|
|
23
|
+
tools[name] = { description, handler };
|
|
24
|
+
},
|
|
25
|
+
tools,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("System Tools", () => {
|
|
30
|
+
let server: ReturnType<typeof createMockServer>;
|
|
31
|
+
let docker: any;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
server = createMockServer();
|
|
36
|
+
docker = {
|
|
37
|
+
info: mockInfo,
|
|
38
|
+
df: mockDf,
|
|
39
|
+
};
|
|
40
|
+
registerSystemTools(server as any, docker);
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("docker_info", () => {
|
|
45
|
+
it("should register docker_info tool", () => {
|
|
46
|
+
expect(server.tools["docker_info"]).toBeDefined();
|
|
47
|
+
expect(server.tools["docker_info"].description).toContain("system information");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should return formatted Docker info", async () => {
|
|
51
|
+
mockInfo.mockResolvedValue({
|
|
52
|
+
ServerVersion: "29.5.3",
|
|
53
|
+
OperatingSystem: "Ubuntu 26.04 LTS",
|
|
54
|
+
KernelVersion: "7.0.0-15-generic",
|
|
55
|
+
Architecture: "x86_64",
|
|
56
|
+
NCPU: 2,
|
|
57
|
+
MemTotal: 4000079872,
|
|
58
|
+
DockerRootDir: "/var/lib/docker",
|
|
59
|
+
Driver: "overlay2",
|
|
60
|
+
ContainersRunning: 3,
|
|
61
|
+
ContainersStopped: 5,
|
|
62
|
+
ContainersPaused: 0,
|
|
63
|
+
Images: 12,
|
|
64
|
+
Labels: ["com.docker.compose.version=2.29.1"],
|
|
65
|
+
ID: "ABC1:DEF2:GHI3:JKL4:MNO5:PQR6:STUV:WXYZ:1234:5678:ABCD:EF90:1234:5678",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await server.tools["docker_info"].handler({});
|
|
69
|
+
const data = JSON.parse(result.content[0].text);
|
|
70
|
+
|
|
71
|
+
expect(data.server_version).toBe("29.5.3");
|
|
72
|
+
expect(data.os).toBe("Ubuntu 26.04 LTS");
|
|
73
|
+
expect(data.kernel).toBe("7.0.0-15-generic");
|
|
74
|
+
expect(data.cpus).toBe(2);
|
|
75
|
+
expect(data.memory_total_human).toBe("3.7 GB");
|
|
76
|
+
expect(data.containers_running).toBe(3);
|
|
77
|
+
expect(data.containers_stopped).toBe(5);
|
|
78
|
+
expect(data.images).toBe(12);
|
|
79
|
+
expect(data.storage_driver).toBe("overlay2");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should handle errors", async () => {
|
|
83
|
+
mockInfo.mockRejectedValue(new Error("Cannot connect to Docker daemon"));
|
|
84
|
+
|
|
85
|
+
const result = await server.tools["docker_info"].handler({});
|
|
86
|
+
|
|
87
|
+
expect(result.isError).toBe(true);
|
|
88
|
+
expect(result.content[0].text).toContain("Error");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("disk_usage", () => {
|
|
93
|
+
it("should register disk_usage tool", () => {
|
|
94
|
+
expect(server.tools["disk_usage"]).toBeDefined();
|
|
95
|
+
expect(server.tools["disk_usage"].description).toContain("disk usage");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should return formatted disk usage", async () => {
|
|
99
|
+
mockDf.mockResolvedValue({
|
|
100
|
+
LayersSize: 623591029,
|
|
101
|
+
Images: [
|
|
102
|
+
{
|
|
103
|
+
Id: "sha256:a6894d60f28f051f4c3e44a6b5f0b669023fc47ea936355d65e5fcc10856767f",
|
|
104
|
+
RepoTags: ["nginx:latest"],
|
|
105
|
+
Size: 395120924,
|
|
106
|
+
Containers: 1,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
Id: "sha256:b2894d60f28f051f4c3e44a6b5f0b669023fc47ea936355d65e5fcc10856767g",
|
|
110
|
+
RepoTags: ["alpine:latest"],
|
|
111
|
+
Size: 13068376,
|
|
112
|
+
Containers: 0,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
Containers: [
|
|
116
|
+
{
|
|
117
|
+
Id: "abc123def456",
|
|
118
|
+
Name: "web-app",
|
|
119
|
+
Image: "nginx:latest",
|
|
120
|
+
Size: 1048576,
|
|
121
|
+
Reclaimable: true,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
Volumes: [
|
|
125
|
+
{
|
|
126
|
+
Name: "data-vol",
|
|
127
|
+
Size: 52428800,
|
|
128
|
+
Reclaimable: false,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
BuildCache: [
|
|
132
|
+
{
|
|
133
|
+
ID: "cache1",
|
|
134
|
+
Type: "regular",
|
|
135
|
+
Description: "pulled from docker.io/library/node:22",
|
|
136
|
+
Size: 9032241,
|
|
137
|
+
InUse: false,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
ID: "cache2",
|
|
141
|
+
Type: "regular",
|
|
142
|
+
Description: "COPY package.json",
|
|
143
|
+
Size: 156559,
|
|
144
|
+
InUse: false,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const result = await server.tools["disk_usage"].handler({});
|
|
150
|
+
const data = JSON.parse(result.content[0].text);
|
|
151
|
+
|
|
152
|
+
expect(data.summary.images.count).toBe(2);
|
|
153
|
+
expect(data.summary.images.total_human).toMatch(/^594\.\d MB$/);
|
|
154
|
+
expect(data.summary.containers.count).toBe(1);
|
|
155
|
+
expect(data.summary.volumes.count).toBe(1);
|
|
156
|
+
expect(data.summary.build_cache.count).toBe(2);
|
|
157
|
+
|
|
158
|
+
// Check image details
|
|
159
|
+
expect(data.images[0].tags).toContain("nginx:latest");
|
|
160
|
+
expect(data.images[0].size_human).toMatch(/^376\.\d MB$/);
|
|
161
|
+
|
|
162
|
+
// Check container details
|
|
163
|
+
expect(data.containers[0].name).toBe("web-app");
|
|
164
|
+
expect(data.containers[0].size_human).toBe("1 MB");
|
|
165
|
+
|
|
166
|
+
// Check volume details
|
|
167
|
+
expect(data.volumes[0].name).toBe("data-vol");
|
|
168
|
+
expect(data.volumes[0].size_human).toBe("50 MB");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should handle empty disk usage", async () => {
|
|
172
|
+
mockDf.mockResolvedValue({
|
|
173
|
+
LayersSize: 0,
|
|
174
|
+
Images: [],
|
|
175
|
+
Containers: [],
|
|
176
|
+
Volumes: [],
|
|
177
|
+
BuildCache: [],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await server.tools["disk_usage"].handler({});
|
|
181
|
+
const data = JSON.parse(result.content[0].text);
|
|
182
|
+
|
|
183
|
+
expect(data.summary.images.count).toBe(0);
|
|
184
|
+
expect(data.summary.containers.count).toBe(0);
|
|
185
|
+
expect(data.images).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should handle errors", async () => {
|
|
189
|
+
mockDf.mockRejectedValue(new Error("Docker API error"));
|
|
190
|
+
|
|
191
|
+
const result = await server.tools["disk_usage"].handler({});
|
|
192
|
+
|
|
193
|
+
expect(result.isError).toBe(true);
|
|
194
|
+
expect(result.content[0].text).toContain("Error");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|