@supernova123/docker-mcp-server 0.3.2 → 0.3.4
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 +104 -0
- package/README.md +24 -0
- package/dist/server.js +5 -1
- package/dist/tools/system.d.ts +4 -0
- package/dist/tools/system.js +97 -0
- package/dist/tools/transfer.d.ts +4 -0
- package/dist/tools/transfer.js +175 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +14 -0
- package/package.json +1 -1
- package/src/server.ts +6 -2
- package/src/tools/system.ts +169 -0
- package/src/tools/transfer.ts +245 -0
- package/src/types.ts +19 -1
- package/tests/system.test.ts +197 -0
- package/tests/transfer.test.ts +176 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to @supernova123/docker-mcp-server will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.3.3] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **docker_info** tool — Docker daemon system information (version, OS, kernel, CPU, memory, storage driver, container/image counts)
|
|
9
|
+
- **disk_usage** tool — Disk usage breakdown by images, containers, volumes, and build cache with human-readable sizes
|
|
10
|
+
|
|
11
|
+
## [0.3.2] - 2026-06-13
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Retry with exponential backoff for transient Docker API errors (`withRetry` wrapper)
|
|
15
|
+
- `isRetryableError` classifier for Docker API error codes
|
|
16
|
+
- 10 new retry/backoff unit tests
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Transient Docker API errors (ECONNRESET, ETIMEDOUT) now retry automatically
|
|
20
|
+
|
|
21
|
+
## [0.3.1] - 2026-06-13
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Startup health check (`checkDockerConnection`) — validates Docker daemon before server start
|
|
25
|
+
- Configurable timeout wrapper (`withTimeout`) — prevents indefinite hangs on slow API calls (default 30s)
|
|
26
|
+
- Structured error classes: `DockerConnectionError`, `DockerTimeoutError`, `DockerPermissionError`
|
|
27
|
+
- Enhanced `formatError()` recognizing structured error types
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Unicode regex in `sanitizeOutput` corrupting log output (#6287)
|
|
31
|
+
|
|
32
|
+
## [0.3.0] - 2026-06-13
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Volume management tools: `list_volumes`, `create_volume`, `remove_volume`, `inspect_volume`, `prune_volumes`
|
|
36
|
+
- 4 new volume tools bringing total to 31
|
|
37
|
+
|
|
38
|
+
## [0.2.5] - 2026-06-12
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- SECURITY.md with 6 audit findings and mitigations
|
|
42
|
+
- Input validation on all tool parameters
|
|
43
|
+
- Output sanitization to prevent prompt injection
|
|
44
|
+
- Size caps on container lists and log output
|
|
45
|
+
- Timeout caps on API calls
|
|
46
|
+
|
|
47
|
+
## [0.2.4] - 2026-06-12
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- MCP annotations on all 31 tools (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
|
|
51
|
+
- Rewrote 6 monitoring tool descriptions for TDQS optimization
|
|
52
|
+
- Rewrote 3 C-grade tool descriptions (`compose_logs`, `restart_container`, `stream_logs`)
|
|
53
|
+
|
|
54
|
+
## [0.2.3] - 2026-06-12
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- TDQS optimization — tool description quality improvements
|
|
58
|
+
|
|
59
|
+
## [0.2.2] - 2026-06-12
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- Glama badges to README
|
|
63
|
+
|
|
64
|
+
## [0.2.1] - 2026-06-12
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
- Renamed monitoring tools for better Glama Quality score
|
|
68
|
+
|
|
69
|
+
## [0.2.0] - 2026-06-12
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
- Fleet monitoring tools: `fleet_status`, `fleet_stats`, `monitor_dashboard`, `watch_events`, `resource_alert_check`, `search_logs`
|
|
73
|
+
- 6 monitoring tools with real Docker API calls
|
|
74
|
+
- 21 unit tests for monitoring functionality
|
|
75
|
+
- Fleet Monitoring section in README
|
|
76
|
+
|
|
77
|
+
## [0.1.6] - 2026-06-11
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- Auto-pull missing images in `run_container`
|
|
81
|
+
- Dockerfile for Docker Hub MCP org submission
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
- Handle 304 error when stopping already-stopped containers
|
|
85
|
+
|
|
86
|
+
## [0.1.4] - 2026-06-11
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
- Resolve compose path — accept both file and directory paths
|
|
90
|
+
|
|
91
|
+
## [0.1.2] - 2026-06-11
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
- Optimized npm SEO keywords and descriptions for discoverability
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
- 20 unit tests
|
|
98
|
+
- Competitive comparison and before/after framing in README
|
|
99
|
+
- Use Cases section with concrete agent scenarios
|
|
100
|
+
|
|
101
|
+
## [0.1.0] - 2026-06-10
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
- 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/server.js
CHANGED
|
@@ -8,10 +8,12 @@ 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";
|
|
12
|
+
import { registerTransferTools } from "./tools/transfer.js";
|
|
11
13
|
export function createServer(docker, options) {
|
|
12
14
|
const server = new McpServer({
|
|
13
15
|
name: "docker-mcp-server",
|
|
14
|
-
version: "0.3.
|
|
16
|
+
version: "0.3.3",
|
|
15
17
|
});
|
|
16
18
|
// Register all tool categories
|
|
17
19
|
registerContainerTools(server, docker);
|
|
@@ -23,6 +25,8 @@ export function createServer(docker, options) {
|
|
|
23
25
|
registerNetworkTools(server, docker);
|
|
24
26
|
registerVolumeTools(server, docker);
|
|
25
27
|
registerMonitoringTools(server, docker);
|
|
28
|
+
registerSystemTools(server, docker);
|
|
29
|
+
registerTransferTools(server, docker);
|
|
26
30
|
return server;
|
|
27
31
|
}
|
|
28
32
|
//# sourceMappingURL=server.js.map
|
|
@@ -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
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Readable } from "stream";
|
|
2
|
+
import { CopyFromContainerSchema, CopyToContainerSchema } from "../types.js";
|
|
3
|
+
import { formatError, withRetry } from "../docker.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read a file from a container using docker exec (cat).
|
|
6
|
+
* Much simpler and more reliable than parsing getArchive tar streams.
|
|
7
|
+
*/
|
|
8
|
+
async function readFileViaExec(docker, containerId, filePath) {
|
|
9
|
+
const container = docker.getContainer(containerId);
|
|
10
|
+
// Create exec to cat the file
|
|
11
|
+
const exec = await container.exec({
|
|
12
|
+
Cmd: ["cat", filePath],
|
|
13
|
+
AttachStdout: true,
|
|
14
|
+
AttachStderr: true,
|
|
15
|
+
});
|
|
16
|
+
// Start exec and collect output
|
|
17
|
+
const stream = await exec.start({ Detach: false });
|
|
18
|
+
const chunks = [];
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
stream.on("data", (chunk) => {
|
|
21
|
+
// Docker exec streams have 8-byte headers per frame
|
|
22
|
+
// Skip the header bytes (first 8 bytes of each frame)
|
|
23
|
+
if (chunk.length > 8) {
|
|
24
|
+
chunks.push(chunk.slice(8));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
stream.on("end", () => {
|
|
28
|
+
const content = Buffer.concat(chunks).toString("utf-8");
|
|
29
|
+
resolve({ content, size: content.length });
|
|
30
|
+
});
|
|
31
|
+
stream.on("error", reject);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get file metadata (size, permissions) via stat command.
|
|
36
|
+
*/
|
|
37
|
+
async function getFileStat(docker, containerId, filePath) {
|
|
38
|
+
const container = docker.getContainer(containerId);
|
|
39
|
+
const exec = await container.exec({
|
|
40
|
+
Cmd: ["stat", "-c", "%s %a %f", filePath],
|
|
41
|
+
AttachStdout: true,
|
|
42
|
+
AttachStderr: true,
|
|
43
|
+
});
|
|
44
|
+
const stream = await exec.start({ Detach: false });
|
|
45
|
+
const chunks = [];
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
stream.on("data", (chunk) => {
|
|
48
|
+
if (chunk.length > 8)
|
|
49
|
+
chunks.push(chunk.slice(8));
|
|
50
|
+
});
|
|
51
|
+
stream.on("end", () => {
|
|
52
|
+
const output = Buffer.concat(chunks).toString("utf-8").trim();
|
|
53
|
+
const [sizeStr, modeStr, typeStr] = output.split(" ");
|
|
54
|
+
const size = parseInt(sizeStr, 10) || 0;
|
|
55
|
+
const mode = modeStr || "644";
|
|
56
|
+
const isFile = typeStr?.startsWith("81") ?? true;
|
|
57
|
+
resolve({ size, mode: `0${mode}`, isFile });
|
|
58
|
+
});
|
|
59
|
+
stream.on("error", reject);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create a minimal tar archive buffer containing a single file.
|
|
64
|
+
* Used for putArchive to inject files into containers.
|
|
65
|
+
*/
|
|
66
|
+
function createSingleFileTar(filePath, content, mode) {
|
|
67
|
+
const contentBuffer = Buffer.from(content, "utf-8");
|
|
68
|
+
const contentBlocks = Math.ceil(contentBuffer.length / 512);
|
|
69
|
+
const totalSize = 512 + contentBlocks * 512;
|
|
70
|
+
const tar = Buffer.alloc(totalSize, 0);
|
|
71
|
+
// File name (100 bytes, null-terminated)
|
|
72
|
+
const nameBytes = Buffer.from(filePath, "utf-8");
|
|
73
|
+
nameBytes.copy(tar, 0, 0, Math.min(nameBytes.length, 100));
|
|
74
|
+
// File mode (8 bytes, octal, null-padded)
|
|
75
|
+
const modeStr = mode.toString(8).padStart(7, "0") + "\0";
|
|
76
|
+
Buffer.from(modeStr).copy(tar, 100);
|
|
77
|
+
// Owner ID (8 bytes) - 0
|
|
78
|
+
Buffer.from("0000000\0").copy(tar, 108);
|
|
79
|
+
// Group ID (8 bytes) - 0
|
|
80
|
+
Buffer.from("0000000\0").copy(tar, 116);
|
|
81
|
+
// File size (12 bytes, octal)
|
|
82
|
+
const sizeStr = contentBuffer.length.toString(8).padStart(11, "0") + "\0";
|
|
83
|
+
Buffer.from(sizeStr).copy(tar, 124);
|
|
84
|
+
// Modification time (12 bytes, octal)
|
|
85
|
+
const mtime = Math.floor(Date.now() / 1000);
|
|
86
|
+
const mtimeStr = mtime.toString(8).padStart(11, "0") + "\0";
|
|
87
|
+
Buffer.from(mtimeStr).copy(tar, 136);
|
|
88
|
+
// Type flag (1 byte) - '0' = regular file
|
|
89
|
+
tar[156] = 0x30; // '0'
|
|
90
|
+
// Checksum placeholder (8 bytes)
|
|
91
|
+
tar.fill(" ", 148, 156);
|
|
92
|
+
// Compute checksum
|
|
93
|
+
let checksum = 0;
|
|
94
|
+
for (let i = 0; i < 512; i++) {
|
|
95
|
+
checksum += tar[i];
|
|
96
|
+
}
|
|
97
|
+
const chkStr = checksum.toString(8).padStart(7, "0") + "\0";
|
|
98
|
+
Buffer.from(chkStr).copy(tar, 148);
|
|
99
|
+
// Copy content
|
|
100
|
+
contentBuffer.copy(tar, 512);
|
|
101
|
+
return tar;
|
|
102
|
+
}
|
|
103
|
+
export function registerTransferTools(server, docker) {
|
|
104
|
+
server.tool("copy_from_container", "Copy a file from a Docker container to read its contents. Returns the file content as text along with metadata (size, permissions). Useful for inspecting config files, logs, or application state inside running containers.", CopyFromContainerSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
105
|
+
try {
|
|
106
|
+
const { content, size } = await withRetry(() => readFileViaExec(docker, params.container_id, params.container_path), { label: "copy_from_container" });
|
|
107
|
+
// Get metadata (stat)
|
|
108
|
+
let mode = "0644";
|
|
109
|
+
try {
|
|
110
|
+
const stat = await getFileStat(docker, params.container_id, params.container_path);
|
|
111
|
+
mode = stat.mode;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// stat might fail if file doesn't exist, exec already validated it
|
|
115
|
+
}
|
|
116
|
+
const result = {
|
|
117
|
+
path: params.container_path,
|
|
118
|
+
content,
|
|
119
|
+
size,
|
|
120
|
+
mode,
|
|
121
|
+
truncated: content.length > 50000,
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text", text: `Error: ${formatError(error)}` }],
|
|
130
|
+
isError: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
server.tool("copy_to_container", "Write file content into a Docker container at the specified path. Overwrites existing files. Useful for injecting configuration files, scripts, or environment files into running or stopped containers.", CopyToContainerSchema.shape, { readOnlyHint: false, idempotentHint: false, openWorldHint: false }, async (params) => {
|
|
135
|
+
try {
|
|
136
|
+
const container = docker.getContainer(params.container_id);
|
|
137
|
+
const mode = params.mode ?? 0o644;
|
|
138
|
+
// Create tar archive with the file
|
|
139
|
+
const tarBuffer = createSingleFileTar(params.container_path, params.content, mode);
|
|
140
|
+
// putArchive expects the path to be the PARENT directory
|
|
141
|
+
const parts = params.container_path.split("/");
|
|
142
|
+
parts.pop(); // remove filename
|
|
143
|
+
const dirPath = parts.join("/") || "/";
|
|
144
|
+
const readable = Readable.from(tarBuffer);
|
|
145
|
+
// Use putArchive with promise API
|
|
146
|
+
await withRetry(() => new Promise((resolve, reject) => {
|
|
147
|
+
container
|
|
148
|
+
.putArchive(readable, { path: dirPath })
|
|
149
|
+
.then(() => resolve())
|
|
150
|
+
.catch(reject);
|
|
151
|
+
}), { label: "copy_to_container" });
|
|
152
|
+
return {
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: "text",
|
|
156
|
+
text: JSON.stringify({
|
|
157
|
+
success: true,
|
|
158
|
+
path: params.container_path,
|
|
159
|
+
size: Buffer.byteLength(params.content, "utf-8"),
|
|
160
|
+
mode: `0${(mode & 0o777).toString(8)}`,
|
|
161
|
+
message: `File written to ${params.container_path} in container ${params.container_id}`,
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: `Error: ${formatError(error)}` }],
|
|
170
|
+
isError: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=transfer.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -403,4 +403,32 @@ 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, {}, {}>;
|
|
408
|
+
export declare const CopyFromContainerSchema: z.ZodObject<{
|
|
409
|
+
container_id: z.ZodString;
|
|
410
|
+
container_path: z.ZodString;
|
|
411
|
+
}, "strip", z.ZodTypeAny, {
|
|
412
|
+
container_id: string;
|
|
413
|
+
container_path: string;
|
|
414
|
+
}, {
|
|
415
|
+
container_id: string;
|
|
416
|
+
container_path: string;
|
|
417
|
+
}>;
|
|
418
|
+
export declare const CopyToContainerSchema: z.ZodObject<{
|
|
419
|
+
container_id: z.ZodString;
|
|
420
|
+
container_path: z.ZodString;
|
|
421
|
+
content: z.ZodString;
|
|
422
|
+
mode: z.ZodOptional<z.ZodNumber>;
|
|
423
|
+
}, "strip", z.ZodTypeAny, {
|
|
424
|
+
container_id: string;
|
|
425
|
+
container_path: string;
|
|
426
|
+
content: string;
|
|
427
|
+
mode?: number | undefined;
|
|
428
|
+
}, {
|
|
429
|
+
container_id: string;
|
|
430
|
+
container_path: string;
|
|
431
|
+
content: string;
|
|
432
|
+
mode?: number | undefined;
|
|
433
|
+
}>;
|
|
406
434
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
CHANGED
|
@@ -173,4 +173,18 @@ 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({});
|
|
179
|
+
// File transfer schemas (v0.3.4)
|
|
180
|
+
export const CopyFromContainerSchema = z.object({
|
|
181
|
+
container_id: z.string().describe("Container ID or name"),
|
|
182
|
+
container_path: z.string().describe("Path inside container to copy from (e.g., '/etc/nginx/nginx.conf')"),
|
|
183
|
+
});
|
|
184
|
+
export const CopyToContainerSchema = z.object({
|
|
185
|
+
container_id: z.string().describe("Container ID or name"),
|
|
186
|
+
container_path: z.string().describe("Destination path inside container (e.g., '/app/config.json')"),
|
|
187
|
+
content: z.string().describe("File content to write (plain text)"),
|
|
188
|
+
mode: z.number().optional().describe("File permissions in octal (e.g., 0o644 = 420). Default: 0o644"),
|
|
189
|
+
});
|
|
176
190
|
//# 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.4",
|
|
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/server.ts
CHANGED
|
@@ -9,6 +9,8 @@ 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";
|
|
13
|
+
import { registerTransferTools } from "./tools/transfer.js";
|
|
12
14
|
|
|
13
15
|
export interface ServerOptions {
|
|
14
16
|
timeoutMs?: number;
|
|
@@ -17,7 +19,7 @@ export interface ServerOptions {
|
|
|
17
19
|
export function createServer(docker: Dockerode, options?: ServerOptions): McpServer {
|
|
18
20
|
const server = new McpServer({
|
|
19
21
|
name: "docker-mcp-server",
|
|
20
|
-
version: "0.3.
|
|
22
|
+
version: "0.3.3",
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
// Register all tool categories
|
|
@@ -30,6 +32,8 @@ export function createServer(docker: Dockerode, options?: ServerOptions): McpSer
|
|
|
30
32
|
registerNetworkTools(server, docker);
|
|
31
33
|
registerVolumeTools(server, docker);
|
|
32
34
|
registerMonitoringTools(server, docker);
|
|
35
|
+
registerSystemTools(server, docker);
|
|
36
|
+
registerTransferTools(server, docker);
|
|
33
37
|
|
|
34
38
|
return server;
|
|
35
|
-
}
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
import { Readable } from "stream";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { CopyFromContainerSchema, CopyToContainerSchema } from "../types.js";
|
|
5
|
+
import { formatError, withRetry } from "../docker.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read a file from a container using docker exec (cat).
|
|
9
|
+
* Much simpler and more reliable than parsing getArchive tar streams.
|
|
10
|
+
*/
|
|
11
|
+
async function readFileViaExec(
|
|
12
|
+
docker: Dockerode,
|
|
13
|
+
containerId: string,
|
|
14
|
+
filePath: string
|
|
15
|
+
): Promise<{ content: string; size: number }> {
|
|
16
|
+
const container = docker.getContainer(containerId);
|
|
17
|
+
|
|
18
|
+
// Create exec to cat the file
|
|
19
|
+
const exec = await container.exec({
|
|
20
|
+
Cmd: ["cat", filePath],
|
|
21
|
+
AttachStdout: true,
|
|
22
|
+
AttachStderr: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Start exec and collect output
|
|
26
|
+
const stream = await exec.start({ Detach: false });
|
|
27
|
+
|
|
28
|
+
const chunks: Buffer[] = [];
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
stream.on("data", (chunk: Buffer) => {
|
|
31
|
+
// Docker exec streams have 8-byte headers per frame
|
|
32
|
+
// Skip the header bytes (first 8 bytes of each frame)
|
|
33
|
+
if (chunk.length > 8) {
|
|
34
|
+
chunks.push(chunk.slice(8));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
stream.on("end", () => {
|
|
38
|
+
const content = Buffer.concat(chunks).toString("utf-8");
|
|
39
|
+
resolve({ content, size: content.length });
|
|
40
|
+
});
|
|
41
|
+
stream.on("error", reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get file metadata (size, permissions) via stat command.
|
|
47
|
+
*/
|
|
48
|
+
async function getFileStat(
|
|
49
|
+
docker: Dockerode,
|
|
50
|
+
containerId: string,
|
|
51
|
+
filePath: string
|
|
52
|
+
): Promise<{ size: number; mode: string; isFile: boolean }> {
|
|
53
|
+
const container = docker.getContainer(containerId);
|
|
54
|
+
|
|
55
|
+
const exec = await container.exec({
|
|
56
|
+
Cmd: ["stat", "-c", "%s %a %f", filePath],
|
|
57
|
+
AttachStdout: true,
|
|
58
|
+
AttachStderr: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const stream = await exec.start({ Detach: false });
|
|
62
|
+
const chunks: Buffer[] = [];
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
stream.on("data", (chunk: Buffer) => {
|
|
66
|
+
if (chunk.length > 8) chunks.push(chunk.slice(8));
|
|
67
|
+
});
|
|
68
|
+
stream.on("end", () => {
|
|
69
|
+
const output = Buffer.concat(chunks).toString("utf-8").trim();
|
|
70
|
+
const [sizeStr, modeStr, typeStr] = output.split(" ");
|
|
71
|
+
const size = parseInt(sizeStr, 10) || 0;
|
|
72
|
+
const mode = modeStr || "644";
|
|
73
|
+
const isFile = typeStr?.startsWith("81") ?? true;
|
|
74
|
+
resolve({ size, mode: `0${mode}`, isFile });
|
|
75
|
+
});
|
|
76
|
+
stream.on("error", reject);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a minimal tar archive buffer containing a single file.
|
|
82
|
+
* Used for putArchive to inject files into containers.
|
|
83
|
+
*/
|
|
84
|
+
function createSingleFileTar(
|
|
85
|
+
filePath: string,
|
|
86
|
+
content: string,
|
|
87
|
+
mode: number
|
|
88
|
+
): Buffer {
|
|
89
|
+
const contentBuffer = Buffer.from(content, "utf-8");
|
|
90
|
+
const contentBlocks = Math.ceil(contentBuffer.length / 512);
|
|
91
|
+
const totalSize = 512 + contentBlocks * 512;
|
|
92
|
+
|
|
93
|
+
const tar = Buffer.alloc(totalSize, 0);
|
|
94
|
+
|
|
95
|
+
// File name (100 bytes, null-terminated)
|
|
96
|
+
const nameBytes = Buffer.from(filePath, "utf-8");
|
|
97
|
+
nameBytes.copy(tar, 0, 0, Math.min(nameBytes.length, 100));
|
|
98
|
+
|
|
99
|
+
// File mode (8 bytes, octal, null-padded)
|
|
100
|
+
const modeStr = mode.toString(8).padStart(7, "0") + "\0";
|
|
101
|
+
Buffer.from(modeStr).copy(tar, 100);
|
|
102
|
+
|
|
103
|
+
// Owner ID (8 bytes) - 0
|
|
104
|
+
Buffer.from("0000000\0").copy(tar, 108);
|
|
105
|
+
|
|
106
|
+
// Group ID (8 bytes) - 0
|
|
107
|
+
Buffer.from("0000000\0").copy(tar, 116);
|
|
108
|
+
|
|
109
|
+
// File size (12 bytes, octal)
|
|
110
|
+
const sizeStr = contentBuffer.length.toString(8).padStart(11, "0") + "\0";
|
|
111
|
+
Buffer.from(sizeStr).copy(tar, 124);
|
|
112
|
+
|
|
113
|
+
// Modification time (12 bytes, octal)
|
|
114
|
+
const mtime = Math.floor(Date.now() / 1000);
|
|
115
|
+
const mtimeStr = mtime.toString(8).padStart(11, "0") + "\0";
|
|
116
|
+
Buffer.from(mtimeStr).copy(tar, 136);
|
|
117
|
+
|
|
118
|
+
// Type flag (1 byte) - '0' = regular file
|
|
119
|
+
tar[156] = 0x30; // '0'
|
|
120
|
+
|
|
121
|
+
// Checksum placeholder (8 bytes)
|
|
122
|
+
tar.fill(" ", 148, 156);
|
|
123
|
+
|
|
124
|
+
// Compute checksum
|
|
125
|
+
let checksum = 0;
|
|
126
|
+
for (let i = 0; i < 512; i++) {
|
|
127
|
+
checksum += tar[i];
|
|
128
|
+
}
|
|
129
|
+
const chkStr = checksum.toString(8).padStart(7, "0") + "\0";
|
|
130
|
+
Buffer.from(chkStr).copy(tar, 148);
|
|
131
|
+
|
|
132
|
+
// Copy content
|
|
133
|
+
contentBuffer.copy(tar, 512);
|
|
134
|
+
|
|
135
|
+
return tar;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function registerTransferTools(
|
|
139
|
+
server: McpServer,
|
|
140
|
+
docker: Dockerode
|
|
141
|
+
): void {
|
|
142
|
+
server.tool(
|
|
143
|
+
"copy_from_container",
|
|
144
|
+
"Copy a file from a Docker container to read its contents. Returns the file content as text along with metadata (size, permissions). Useful for inspecting config files, logs, or application state inside running containers.",
|
|
145
|
+
CopyFromContainerSchema.shape,
|
|
146
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
147
|
+
async (params) => {
|
|
148
|
+
try {
|
|
149
|
+
const { content, size } = await withRetry(
|
|
150
|
+
() => readFileViaExec(docker, params.container_id, params.container_path),
|
|
151
|
+
{ label: "copy_from_container" }
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Get metadata (stat)
|
|
155
|
+
let mode = "0644";
|
|
156
|
+
try {
|
|
157
|
+
const stat = await getFileStat(
|
|
158
|
+
docker,
|
|
159
|
+
params.container_id,
|
|
160
|
+
params.container_path
|
|
161
|
+
);
|
|
162
|
+
mode = stat.mode;
|
|
163
|
+
} catch {
|
|
164
|
+
// stat might fail if file doesn't exist, exec already validated it
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = {
|
|
168
|
+
path: params.container_path,
|
|
169
|
+
content,
|
|
170
|
+
size,
|
|
171
|
+
mode,
|
|
172
|
+
truncated: content.length > 50000,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
177
|
+
};
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text", text: `Error: ${formatError(error)}` }],
|
|
181
|
+
isError: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
server.tool(
|
|
188
|
+
"copy_to_container",
|
|
189
|
+
"Write file content into a Docker container at the specified path. Overwrites existing files. Useful for injecting configuration files, scripts, or environment files into running or stopped containers.",
|
|
190
|
+
CopyToContainerSchema.shape,
|
|
191
|
+
{ readOnlyHint: false, idempotentHint: false, openWorldHint: false },
|
|
192
|
+
async (params) => {
|
|
193
|
+
try {
|
|
194
|
+
const container = docker.getContainer(params.container_id);
|
|
195
|
+
const mode = params.mode ?? 0o644;
|
|
196
|
+
|
|
197
|
+
// Create tar archive with the file
|
|
198
|
+
const tarBuffer = createSingleFileTar(
|
|
199
|
+
params.container_path,
|
|
200
|
+
params.content,
|
|
201
|
+
mode
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// putArchive expects the path to be the PARENT directory
|
|
205
|
+
const parts = params.container_path.split("/");
|
|
206
|
+
parts.pop(); // remove filename
|
|
207
|
+
const dirPath = parts.join("/") || "/";
|
|
208
|
+
|
|
209
|
+
const readable = Readable.from(tarBuffer);
|
|
210
|
+
|
|
211
|
+
// Use putArchive with promise API
|
|
212
|
+
await withRetry(
|
|
213
|
+
() =>
|
|
214
|
+
new Promise<void>((resolve, reject) => {
|
|
215
|
+
container
|
|
216
|
+
.putArchive(readable as any, { path: dirPath })
|
|
217
|
+
.then(() => resolve())
|
|
218
|
+
.catch(reject);
|
|
219
|
+
}),
|
|
220
|
+
{ label: "copy_to_container" }
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text",
|
|
227
|
+
text: JSON.stringify({
|
|
228
|
+
success: true,
|
|
229
|
+
path: params.container_path,
|
|
230
|
+
size: Buffer.byteLength(params.content, "utf-8"),
|
|
231
|
+
mode: `0${(mode & 0o777).toString(8)}`,
|
|
232
|
+
message: `File written to ${params.container_path} in container ${params.container_id}`,
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text", text: `Error: ${formatError(error)}` }],
|
|
240
|
+
isError: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -215,4 +215,22 @@ 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({});
|
|
224
|
+
|
|
225
|
+
// File transfer schemas (v0.3.4)
|
|
226
|
+
export const CopyFromContainerSchema = z.object({
|
|
227
|
+
container_id: z.string().describe("Container ID or name"),
|
|
228
|
+
container_path: z.string().describe("Path inside container to copy from (e.g., '/etc/nginx/nginx.conf')"),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
export const CopyToContainerSchema = z.object({
|
|
232
|
+
container_id: z.string().describe("Container ID or name"),
|
|
233
|
+
container_path: z.string().describe("Destination path inside container (e.g., '/app/config.json')"),
|
|
234
|
+
content: z.string().describe("File content to write (plain text)"),
|
|
235
|
+
mode: z.number().optional().describe("File permissions in octal (e.g., 0o644 = 420). Default: 0o644"),
|
|
236
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { PassThrough } from "stream";
|
|
3
|
+
|
|
4
|
+
const { mockExec, mockPutArchive } = vi.hoisted(() => ({
|
|
5
|
+
mockExec: vi.fn(),
|
|
6
|
+
mockPutArchive: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("dockerode", () => {
|
|
10
|
+
return {
|
|
11
|
+
default: vi.fn().mockImplementation(() => ({
|
|
12
|
+
getContainer: vi.fn().mockReturnValue({
|
|
13
|
+
exec: mockExec,
|
|
14
|
+
putArchive: mockPutArchive,
|
|
15
|
+
}),
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { registerTransferTools } from "../src/tools/transfer.js";
|
|
21
|
+
import { createDockerClient } from "../src/docker.js";
|
|
22
|
+
|
|
23
|
+
function createMockServer() {
|
|
24
|
+
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
25
|
+
return {
|
|
26
|
+
tool: (
|
|
27
|
+
name: string,
|
|
28
|
+
description: string,
|
|
29
|
+
_schemaOrAnnotations: unknown,
|
|
30
|
+
_annotationsOrHandler: unknown,
|
|
31
|
+
_maybeHandler?: Function
|
|
32
|
+
) => {
|
|
33
|
+
const handler =
|
|
34
|
+
typeof _annotationsOrHandler === "function"
|
|
35
|
+
? _annotationsOrHandler
|
|
36
|
+
: (_maybeHandler as Function);
|
|
37
|
+
tools[name] = { description, handler };
|
|
38
|
+
},
|
|
39
|
+
tools,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Transfer Tools", () => {
|
|
44
|
+
let server: ReturnType<typeof createMockServer>;
|
|
45
|
+
let docker: ReturnType<typeof createDockerClient>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
server = createMockServer();
|
|
50
|
+
docker = createDockerClient();
|
|
51
|
+
registerTransferTools(server as any, docker);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("copy_from_container", () => {
|
|
55
|
+
it("calls exec with correct command", async () => {
|
|
56
|
+
// Mock exec to reject (error path tests the call chain)
|
|
57
|
+
mockExec.mockRejectedValue(new Error("No such container"));
|
|
58
|
+
|
|
59
|
+
const result = await server.tools["copy_from_container"].handler({
|
|
60
|
+
container_id: "test-container",
|
|
61
|
+
container_path: "/etc/hosts",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Verify exec was called with the correct options
|
|
65
|
+
expect(mockExec).toHaveBeenCalledWith({
|
|
66
|
+
Cmd: ["cat", "/etc/hosts"],
|
|
67
|
+
AttachStdout: true,
|
|
68
|
+
AttachStderr: true,
|
|
69
|
+
});
|
|
70
|
+
expect(result.isError).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles exec errors gracefully", async () => {
|
|
74
|
+
mockExec.mockRejectedValue(new Error("No such container"));
|
|
75
|
+
|
|
76
|
+
const result = await server.tools["copy_from_container"].handler({
|
|
77
|
+
container_id: "nonexistent",
|
|
78
|
+
container_path: "/etc/hosts",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.isError).toBe(true);
|
|
82
|
+
expect(result.content[0].text).toContain("Error");
|
|
83
|
+
expect(result.content[0].text).toContain("No such container");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("tool is registered with correct metadata", () => {
|
|
87
|
+
const tool = server.tools["copy_from_container"];
|
|
88
|
+
expect(tool).toBeDefined();
|
|
89
|
+
expect(tool.description).toContain("Copy a file from a Docker container");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("copy_to_container", () => {
|
|
94
|
+
it("writes a file into container", async () => {
|
|
95
|
+
mockPutArchive.mockResolvedValue({});
|
|
96
|
+
|
|
97
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
98
|
+
container_id: "abc123",
|
|
99
|
+
container_path: "/app/config.json",
|
|
100
|
+
content: '{"key": "value"}',
|
|
101
|
+
mode: 0o644,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const data = JSON.parse(result.content[0].text);
|
|
105
|
+
expect(data.success).toBe(true);
|
|
106
|
+
expect(data.path).toBe("/app/config.json");
|
|
107
|
+
expect(data.size).toBe(16); // '{"key": "value"}' = 16 bytes
|
|
108
|
+
expect(result.isError).toBeFalsy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("uses default mode 0o644 when not specified", async () => {
|
|
112
|
+
mockPutArchive.mockResolvedValue({});
|
|
113
|
+
|
|
114
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
115
|
+
container_id: "abc123",
|
|
116
|
+
container_path: "/app/script.sh",
|
|
117
|
+
content: "#!/bin/bash\necho hello",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const data = JSON.parse(result.content[0].text);
|
|
121
|
+
expect(data.mode).toBe("0644");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("calls putArchive with parent directory", async () => {
|
|
125
|
+
mockPutArchive.mockResolvedValue({});
|
|
126
|
+
|
|
127
|
+
await server.tools["copy_to_container"].handler({
|
|
128
|
+
container_id: "abc123",
|
|
129
|
+
container_path: "/app/config.json",
|
|
130
|
+
content: "data",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// putArchive should be called (stream, { path: "/app" })
|
|
134
|
+
expect(mockPutArchive).toHaveBeenCalled();
|
|
135
|
+
const callArgs = mockPutArchive.mock.calls[0];
|
|
136
|
+
expect(callArgs[1]).toEqual({ path: "/app" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles nested paths correctly", async () => {
|
|
140
|
+
mockPutArchive.mockResolvedValue({});
|
|
141
|
+
|
|
142
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
143
|
+
container_id: "abc123",
|
|
144
|
+
container_path: "/etc/nginx/nginx.conf",
|
|
145
|
+
content: "worker_processes 1;",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const data = JSON.parse(result.content[0].text);
|
|
149
|
+
expect(data.success).toBe(true);
|
|
150
|
+
expect(data.path).toBe("/etc/nginx/nginx.conf");
|
|
151
|
+
|
|
152
|
+
// Parent dir should be /etc/nginx
|
|
153
|
+
const callArgs = mockPutArchive.mock.calls[0];
|
|
154
|
+
expect(callArgs[1]).toEqual({ path: "/etc/nginx" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles putArchive errors gracefully", async () => {
|
|
158
|
+
mockPutArchive.mockRejectedValue(new Error("Permission denied"));
|
|
159
|
+
|
|
160
|
+
const result = await server.tools["copy_to_container"].handler({
|
|
161
|
+
container_id: "abc123",
|
|
162
|
+
container_path: "/root/secret.txt",
|
|
163
|
+
content: "data",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.isError).toBe(true);
|
|
167
|
+
expect(result.content[0].text).toContain("Error");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("tool is registered with correct metadata", () => {
|
|
171
|
+
const tool = server.tools["copy_to_container"];
|
|
172
|
+
expect(tool).toBeDefined();
|
|
173
|
+
expect(tool.description).toContain("Write file content into a Docker container");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|