@supernova123/docker-mcp-server 0.2.5 → 0.3.1
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/dist/docker.d.ts +29 -0
- package/dist/docker.js +71 -1
- package/dist/index.js +10 -2
- package/dist/server.d.ts +4 -1
- package/dist/server.js +4 -2
- package/dist/tools/volume.d.ts +4 -0
- package/dist/tools/volume.js +95 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +16 -0
- package/package.json +1 -1
- package/src/docker.ts +83 -1
- package/src/index.ts +13 -2
- package/src/server.ts +8 -2
- package/src/tools/volume.ts +125 -0
- package/src/types.ts +20 -0
- package/tests/image.test.ts +1 -1
- package/tests/monitoring.test.ts +1 -1
- package/tests/volume.test.ts +230 -0
package/dist/docker.d.ts
CHANGED
|
@@ -5,6 +5,35 @@ export interface DockerClientOptions {
|
|
|
5
5
|
port?: number;
|
|
6
6
|
}
|
|
7
7
|
export declare function createDockerClient(options?: DockerClientOptions): Dockerode;
|
|
8
|
+
/**
|
|
9
|
+
* Structured error types for programmatic error classification.
|
|
10
|
+
* Helps AI clients decide whether to retry (retryable) or report (permanent).
|
|
11
|
+
*/
|
|
12
|
+
export declare class DockerConnectionError extends Error {
|
|
13
|
+
retryable: boolean;
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
export declare class DockerTimeoutError extends Error {
|
|
17
|
+
retryable: boolean;
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
export declare class DockerPermissionError extends Error {
|
|
21
|
+
retryable: boolean;
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Check Docker daemon connectivity at startup.
|
|
26
|
+
* Returns a structured error if Docker is unreachable.
|
|
27
|
+
*/
|
|
28
|
+
export declare function checkDockerConnection(docker: Dockerode): Promise<{
|
|
29
|
+
ok: boolean;
|
|
30
|
+
error?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Run a Docker API call with a configurable timeout.
|
|
34
|
+
* Returns structured error on timeout instead of hanging indefinitely.
|
|
35
|
+
*/
|
|
36
|
+
export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
|
|
8
37
|
export declare function formatError(error: unknown): string;
|
|
9
38
|
export declare function formatContainer(container: Dockerode.ContainerInfo): Record<string, unknown>;
|
|
10
39
|
export declare function formatImage(image: Dockerode.ImageInfo): Record<string, unknown>;
|
package/dist/docker.js
CHANGED
|
@@ -9,7 +9,77 @@ export function createDockerClient(options) {
|
|
|
9
9
|
// Default: local socket
|
|
10
10
|
return new Dockerode({ socketPath: "/var/run/docker.sock" });
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Structured error types for programmatic error classification.
|
|
14
|
+
* Helps AI clients decide whether to retry (retryable) or report (permanent).
|
|
15
|
+
*/
|
|
16
|
+
export class DockerConnectionError extends Error {
|
|
17
|
+
retryable = false;
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "DockerConnectionError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class DockerTimeoutError extends Error {
|
|
24
|
+
retryable = true;
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "DockerTimeoutError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class DockerPermissionError extends Error {
|
|
31
|
+
retryable = false;
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "DockerPermissionError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Check Docker daemon connectivity at startup.
|
|
39
|
+
* Returns a structured error if Docker is unreachable.
|
|
40
|
+
*/
|
|
41
|
+
export async function checkDockerConnection(docker) {
|
|
42
|
+
try {
|
|
43
|
+
await docker.ping();
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
48
|
+
if (msg.includes("EACCES") || msg.includes("Permission denied")) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `DockerConnectionError: Cannot access Docker socket — ${msg}. Fix: add user to docker group (sudo usermod -aG docker $USER) or run as root.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (msg.includes("ENOENT") || msg.includes("no such file")) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: `DockerConnectionError: Docker socket not found at /var/run/docker.sock — ${msg}. Fix: install Docker (https://docs.docker.com/engine/install/) and ensure the daemon is running.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `DockerConnectionError: Cannot connect to Docker daemon — ${msg}. Fix: ensure Docker is running (systemctl status docker).`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run a Docker API call with a configurable timeout.
|
|
68
|
+
* Returns structured error on timeout instead of hanging indefinitely.
|
|
69
|
+
*/
|
|
70
|
+
export async function withTimeout(promise, ms, label) {
|
|
71
|
+
return Promise.race([
|
|
72
|
+
promise,
|
|
73
|
+
new Promise((_, reject) => setTimeout(() => reject(new DockerTimeoutError(`Docker API call "${label}" timed out after ${ms}ms`)), ms)),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
12
76
|
export function formatError(error) {
|
|
77
|
+
if (error instanceof DockerConnectionError)
|
|
78
|
+
return `${error.name}: ${error.message}`;
|
|
79
|
+
if (error instanceof DockerTimeoutError)
|
|
80
|
+
return `${error.name}: ${error.message}`;
|
|
81
|
+
if (error instanceof DockerPermissionError)
|
|
82
|
+
return `${error.name}: ${error.message}`;
|
|
13
83
|
if (error instanceof Error)
|
|
14
84
|
return error.message;
|
|
15
85
|
if (typeof error === "string")
|
|
@@ -57,7 +127,7 @@ export function sanitizeOutput(text, maxLength = 1_000_000) {
|
|
|
57
127
|
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
58
128
|
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
59
129
|
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
60
|
-
text = text.replace(/[\
|
|
130
|
+
text = text.replace(/[\u{E0000}-\u{E007F}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu, "");
|
|
61
131
|
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
62
132
|
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
63
133
|
// Truncate to cap memory and LLM context cost
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
-
import { createDockerClient } from "./docker.js";
|
|
3
|
+
import { createDockerClient, checkDockerConnection } from "./docker.js";
|
|
4
4
|
import { createServer } from "./server.js";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
6
|
async function main() {
|
|
6
7
|
const docker = createDockerClient();
|
|
7
|
-
|
|
8
|
+
// Startup health check: verify Docker daemon is reachable
|
|
9
|
+
const health = await checkDockerConnection(docker);
|
|
10
|
+
if (!health.ok) {
|
|
11
|
+
process.stderr.write(`${health.error}\n`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
process.stderr.write("Docker daemon reachable\n");
|
|
15
|
+
const server = createServer(docker, { timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
8
16
|
const transport = new StdioServerTransport();
|
|
9
17
|
await server.connect(transport);
|
|
10
18
|
process.stderr.write("Docker MCP Server running on stdio\n");
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import Dockerode from "dockerode";
|
|
3
|
-
export
|
|
3
|
+
export interface ServerOptions {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function createServer(docker: Dockerode, options?: ServerOptions): McpServer;
|
|
4
7
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
CHANGED
|
@@ -6,11 +6,12 @@ import { registerHealthTools } from "./tools/health.js";
|
|
|
6
6
|
import { registerLogsTools } from "./tools/logs.js";
|
|
7
7
|
import { registerExecTools } from "./tools/exec.js";
|
|
8
8
|
import { registerNetworkTools } from "./tools/network.js";
|
|
9
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
9
10
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
10
|
-
export function createServer(docker) {
|
|
11
|
+
export function createServer(docker, options) {
|
|
11
12
|
const server = new McpServer({
|
|
12
13
|
name: "docker-mcp-server",
|
|
13
|
-
version: "0.
|
|
14
|
+
version: "0.3.1",
|
|
14
15
|
});
|
|
15
16
|
// Register all tool categories
|
|
16
17
|
registerContainerTools(server, docker);
|
|
@@ -20,6 +21,7 @@ export function createServer(docker) {
|
|
|
20
21
|
registerLogsTools(server, docker);
|
|
21
22
|
registerExecTools(server, docker);
|
|
22
23
|
registerNetworkTools(server, docker);
|
|
24
|
+
registerVolumeTools(server, docker);
|
|
23
25
|
registerMonitoringTools(server, docker);
|
|
24
26
|
return server;
|
|
25
27
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CreateVolumeSchema, InspectVolumeSchema, RemoveVolumeSchema, PruneVolumesSchema, } from "../types.js";
|
|
2
|
+
import { formatError } from "../docker.js";
|
|
3
|
+
export function registerVolumeTools(server, docker) {
|
|
4
|
+
server.tool("create_volume", "Create a Docker volume with optional driver, labels, and options. Returns volume name, driver, and mountpoint.", CreateVolumeSchema.shape, { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
5
|
+
try {
|
|
6
|
+
const result = await docker.createVolume({
|
|
7
|
+
Name: params.name,
|
|
8
|
+
Driver: params.driver || "local",
|
|
9
|
+
Labels: params.labels,
|
|
10
|
+
DriverOpts: params.options,
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
content: [{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: JSON.stringify({
|
|
16
|
+
name: result.Name,
|
|
17
|
+
driver: result.Driver,
|
|
18
|
+
mountpoint: result.Mountpoint,
|
|
19
|
+
created: result.CreatedAt,
|
|
20
|
+
labels: result.Labels,
|
|
21
|
+
}, null, 2),
|
|
22
|
+
}],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
server.tool("inspect_volume", "Inspect a Docker volume. Returns detailed info including name, driver, mountpoint, labels, scope, and usage data.", InspectVolumeSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
30
|
+
try {
|
|
31
|
+
const volume = docker.getVolume(params.name);
|
|
32
|
+
const info = await volume.inspect();
|
|
33
|
+
return {
|
|
34
|
+
content: [{
|
|
35
|
+
type: "text",
|
|
36
|
+
text: JSON.stringify({
|
|
37
|
+
name: info.Name,
|
|
38
|
+
driver: info.Driver,
|
|
39
|
+
mountpoint: info.Mountpoint,
|
|
40
|
+
labels: info.Labels,
|
|
41
|
+
scope: info.Scope,
|
|
42
|
+
options: info.Options,
|
|
43
|
+
status: info.Status || null,
|
|
44
|
+
usage: info.UsageData || null,
|
|
45
|
+
}, null, 2),
|
|
46
|
+
}],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
server.tool("remove_volume", "Remove a Docker volume. Use force=true to remove even if in use by containers.", RemoveVolumeSchema.shape, { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
|
|
54
|
+
try {
|
|
55
|
+
const volume = docker.getVolume(params.name);
|
|
56
|
+
await volume.remove({ force: params.force || false });
|
|
57
|
+
return {
|
|
58
|
+
content: [{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: JSON.stringify({ success: true, name: params.name, message: `Volume '${params.name}' removed` }),
|
|
61
|
+
}],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
server.tool("prune_volumes", "Remove all unused Docker volumes. Returns count of removed volumes and reclaimed space.", PruneVolumesSchema.shape, { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async (params) => {
|
|
69
|
+
try {
|
|
70
|
+
const filters = {};
|
|
71
|
+
if (params.filter) {
|
|
72
|
+
const match = params.filter.match(/label=(.+)/);
|
|
73
|
+
if (match) {
|
|
74
|
+
filters.label = [match[1]];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const result = await docker.pruneVolumes({
|
|
78
|
+
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
content: [{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: JSON.stringify({
|
|
84
|
+
volumes_deleted: result.VolumesDeleted || [],
|
|
85
|
+
space_reclaimed: result.SpaceReclaimed || 0,
|
|
86
|
+
}, null, 2),
|
|
87
|
+
}],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=volume.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -306,6 +306,46 @@ export declare const ListVolumesSchema: z.ZodObject<{
|
|
|
306
306
|
}, {
|
|
307
307
|
filter?: string | undefined;
|
|
308
308
|
}>;
|
|
309
|
+
export declare const CreateVolumeSchema: z.ZodObject<{
|
|
310
|
+
name: z.ZodString;
|
|
311
|
+
driver: z.ZodOptional<z.ZodString>;
|
|
312
|
+
labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
313
|
+
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
314
|
+
}, "strip", z.ZodTypeAny, {
|
|
315
|
+
name: string;
|
|
316
|
+
labels?: Record<string, string> | undefined;
|
|
317
|
+
options?: Record<string, string> | undefined;
|
|
318
|
+
driver?: string | undefined;
|
|
319
|
+
}, {
|
|
320
|
+
name: string;
|
|
321
|
+
labels?: Record<string, string> | undefined;
|
|
322
|
+
options?: Record<string, string> | undefined;
|
|
323
|
+
driver?: string | undefined;
|
|
324
|
+
}>;
|
|
325
|
+
export declare const InspectVolumeSchema: z.ZodObject<{
|
|
326
|
+
name: z.ZodString;
|
|
327
|
+
}, "strip", z.ZodTypeAny, {
|
|
328
|
+
name: string;
|
|
329
|
+
}, {
|
|
330
|
+
name: string;
|
|
331
|
+
}>;
|
|
332
|
+
export declare const RemoveVolumeSchema: z.ZodObject<{
|
|
333
|
+
name: z.ZodString;
|
|
334
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
335
|
+
}, "strip", z.ZodTypeAny, {
|
|
336
|
+
name: string;
|
|
337
|
+
force?: boolean | undefined;
|
|
338
|
+
}, {
|
|
339
|
+
name: string;
|
|
340
|
+
force?: boolean | undefined;
|
|
341
|
+
}>;
|
|
342
|
+
export declare const PruneVolumesSchema: z.ZodObject<{
|
|
343
|
+
filter: z.ZodOptional<z.ZodString>;
|
|
344
|
+
}, "strip", z.ZodTypeAny, {
|
|
345
|
+
filter?: string | undefined;
|
|
346
|
+
}, {
|
|
347
|
+
filter?: string | undefined;
|
|
348
|
+
}>;
|
|
309
349
|
export declare const ContainerHealthStatusSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
310
350
|
export declare const ContainerResourceUsageSchema: z.ZodObject<{
|
|
311
351
|
sort_by: z.ZodOptional<z.ZodEnum<["cpu", "memory", "network"]>>;
|
package/dist/types.js
CHANGED
|
@@ -133,6 +133,22 @@ export const ListNetworksSchema = z.object({
|
|
|
133
133
|
export const ListVolumesSchema = z.object({
|
|
134
134
|
filter: z.string().optional().describe("Filter by name or driver"),
|
|
135
135
|
});
|
|
136
|
+
export const CreateVolumeSchema = z.object({
|
|
137
|
+
name: z.string().min(1).max(255).describe("Volume name"),
|
|
138
|
+
driver: z.string().optional().describe("Volume driver (default: 'local')"),
|
|
139
|
+
labels: z.record(z.string(), z.string()).optional().describe("Labels to apply to the volume"),
|
|
140
|
+
options: z.record(z.string(), z.string()).optional().describe("Driver-specific options"),
|
|
141
|
+
});
|
|
142
|
+
export const InspectVolumeSchema = z.object({
|
|
143
|
+
name: z.string().min(1).describe("Volume name or ID to inspect"),
|
|
144
|
+
});
|
|
145
|
+
export const RemoveVolumeSchema = z.object({
|
|
146
|
+
name: z.string().min(1).describe("Volume name or ID to remove"),
|
|
147
|
+
force: z.boolean().optional().describe("Force removal even if in use (default: false)"),
|
|
148
|
+
});
|
|
149
|
+
export const PruneVolumesSchema = z.object({
|
|
150
|
+
filter: z.string().optional().describe("Filter by label (e.g., 'label=key=value')"),
|
|
151
|
+
});
|
|
136
152
|
// Monitoring schemas (v0.2.0)
|
|
137
153
|
export const ContainerHealthStatusSchema = z.object({});
|
|
138
154
|
export const ContainerResourceUsageSchema = z.object({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supernova123/docker-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
@@ -17,7 +17,89 @@ export function createDockerClient(options?: DockerClientOptions): Dockerode {
|
|
|
17
17
|
return new Dockerode({ socketPath: "/var/run/docker.sock" });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Structured error types for programmatic error classification.
|
|
22
|
+
* Helps AI clients decide whether to retry (retryable) or report (permanent).
|
|
23
|
+
*/
|
|
24
|
+
export class DockerConnectionError extends Error {
|
|
25
|
+
retryable = false;
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "DockerConnectionError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DockerTimeoutError extends Error {
|
|
33
|
+
retryable = true;
|
|
34
|
+
constructor(message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "DockerTimeoutError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class DockerPermissionError extends Error {
|
|
41
|
+
retryable = false;
|
|
42
|
+
constructor(message: string) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "DockerPermissionError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check Docker daemon connectivity at startup.
|
|
50
|
+
* Returns a structured error if Docker is unreachable.
|
|
51
|
+
*/
|
|
52
|
+
export async function checkDockerConnection(
|
|
53
|
+
docker: Dockerode
|
|
54
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
55
|
+
try {
|
|
56
|
+
await docker.ping();
|
|
57
|
+
return { ok: true };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
60
|
+
if (msg.includes("EACCES") || msg.includes("Permission denied")) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `DockerConnectionError: Cannot access Docker socket — ${msg}. Fix: add user to docker group (sudo usermod -aG docker $USER) or run as root.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (msg.includes("ENOENT") || msg.includes("no such file")) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `DockerConnectionError: Docker socket not found at /var/run/docker.sock — ${msg}. Fix: install Docker (https://docs.docker.com/engine/install/) and ensure the daemon is running.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: `DockerConnectionError: Cannot connect to Docker daemon — ${msg}. Fix: ensure Docker is running (systemctl status docker).`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run a Docker API call with a configurable timeout.
|
|
81
|
+
* Returns structured error on timeout instead of hanging indefinitely.
|
|
82
|
+
*/
|
|
83
|
+
export async function withTimeout<T>(
|
|
84
|
+
promise: Promise<T>,
|
|
85
|
+
ms: number,
|
|
86
|
+
label: string
|
|
87
|
+
): Promise<T> {
|
|
88
|
+
return Promise.race([
|
|
89
|
+
promise,
|
|
90
|
+
new Promise<never>((_, reject) =>
|
|
91
|
+
setTimeout(
|
|
92
|
+
() => reject(new DockerTimeoutError(`Docker API call "${label}" timed out after ${ms}ms`)),
|
|
93
|
+
ms
|
|
94
|
+
)
|
|
95
|
+
),
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
20
99
|
export function formatError(error: unknown): string {
|
|
100
|
+
if (error instanceof DockerConnectionError) return `${error.name}: ${error.message}`;
|
|
101
|
+
if (error instanceof DockerTimeoutError) return `${error.name}: ${error.message}`;
|
|
102
|
+
if (error instanceof DockerPermissionError) return `${error.name}: ${error.message}`;
|
|
21
103
|
if (error instanceof Error) return error.message;
|
|
22
104
|
if (typeof error === "string") return error;
|
|
23
105
|
return String(error);
|
|
@@ -66,7 +148,7 @@ export function sanitizeOutput(text: string, maxLength = 1_000_000): string {
|
|
|
66
148
|
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
67
149
|
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
68
150
|
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
69
|
-
text = text.replace(/[\
|
|
151
|
+
text = text.replace(/[\u{E0000}-\u{E007F}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu, "");
|
|
70
152
|
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
71
153
|
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
72
154
|
// Truncate to cap memory and LLM context cost
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { createDockerClient } from "./docker.js";
|
|
4
|
+
import { createDockerClient, checkDockerConnection } from "./docker.js";
|
|
5
5
|
import { createServer } from "./server.js";
|
|
6
6
|
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
7
9
|
async function main() {
|
|
8
10
|
const docker = createDockerClient();
|
|
9
|
-
|
|
11
|
+
|
|
12
|
+
// Startup health check: verify Docker daemon is reachable
|
|
13
|
+
const health = await checkDockerConnection(docker);
|
|
14
|
+
if (!health.ok) {
|
|
15
|
+
process.stderr.write(`${health.error}\n`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
process.stderr.write("Docker daemon reachable\n");
|
|
19
|
+
|
|
20
|
+
const server = createServer(docker, { timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
10
21
|
|
|
11
22
|
const transport = new StdioServerTransport();
|
|
12
23
|
await server.connect(transport);
|
package/src/server.ts
CHANGED
|
@@ -7,12 +7,17 @@ import { registerHealthTools } from "./tools/health.js";
|
|
|
7
7
|
import { registerLogsTools } from "./tools/logs.js";
|
|
8
8
|
import { registerExecTools } from "./tools/exec.js";
|
|
9
9
|
import { registerNetworkTools } from "./tools/network.js";
|
|
10
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
10
11
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
11
12
|
|
|
12
|
-
export
|
|
13
|
+
export interface ServerOptions {
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createServer(docker: Dockerode, options?: ServerOptions): McpServer {
|
|
13
18
|
const server = new McpServer({
|
|
14
19
|
name: "docker-mcp-server",
|
|
15
|
-
version: "0.
|
|
20
|
+
version: "0.3.1",
|
|
16
21
|
});
|
|
17
22
|
|
|
18
23
|
// Register all tool categories
|
|
@@ -23,6 +28,7 @@ export function createServer(docker: Dockerode): McpServer {
|
|
|
23
28
|
registerLogsTools(server, docker);
|
|
24
29
|
registerExecTools(server, docker);
|
|
25
30
|
registerNetworkTools(server, docker);
|
|
31
|
+
registerVolumeTools(server, docker);
|
|
26
32
|
registerMonitoringTools(server, docker);
|
|
27
33
|
|
|
28
34
|
return server;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import {
|
|
4
|
+
CreateVolumeSchema,
|
|
5
|
+
InspectVolumeSchema,
|
|
6
|
+
RemoveVolumeSchema,
|
|
7
|
+
PruneVolumesSchema,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
import { formatError } from "../docker.js";
|
|
10
|
+
|
|
11
|
+
export function registerVolumeTools(server: McpServer, docker: Dockerode): void {
|
|
12
|
+
server.tool(
|
|
13
|
+
"create_volume",
|
|
14
|
+
"Create a Docker volume with optional driver, labels, and options. Returns volume name, driver, and mountpoint.",
|
|
15
|
+
CreateVolumeSchema.shape,
|
|
16
|
+
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
17
|
+
async (params) => {
|
|
18
|
+
try {
|
|
19
|
+
const result = await docker.createVolume({
|
|
20
|
+
Name: params.name,
|
|
21
|
+
Driver: params.driver || "local",
|
|
22
|
+
Labels: params.labels,
|
|
23
|
+
DriverOpts: params.options,
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
content: [{
|
|
27
|
+
type: "text",
|
|
28
|
+
text: JSON.stringify({
|
|
29
|
+
name: result.Name,
|
|
30
|
+
driver: result.Driver,
|
|
31
|
+
mountpoint: result.Mountpoint,
|
|
32
|
+
created: result.CreatedAt,
|
|
33
|
+
labels: result.Labels,
|
|
34
|
+
}, null, 2),
|
|
35
|
+
}],
|
|
36
|
+
};
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
server.tool(
|
|
44
|
+
"inspect_volume",
|
|
45
|
+
"Inspect a Docker volume. Returns detailed info including name, driver, mountpoint, labels, scope, and usage data.",
|
|
46
|
+
InspectVolumeSchema.shape,
|
|
47
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
48
|
+
async (params) => {
|
|
49
|
+
try {
|
|
50
|
+
const volume = docker.getVolume(params.name);
|
|
51
|
+
const info = await volume.inspect();
|
|
52
|
+
return {
|
|
53
|
+
content: [{
|
|
54
|
+
type: "text",
|
|
55
|
+
text: JSON.stringify({
|
|
56
|
+
name: info.Name,
|
|
57
|
+
driver: info.Driver,
|
|
58
|
+
mountpoint: info.Mountpoint,
|
|
59
|
+
labels: info.Labels,
|
|
60
|
+
scope: info.Scope,
|
|
61
|
+
options: info.Options,
|
|
62
|
+
status: info.Status || null,
|
|
63
|
+
usage: info.UsageData || null,
|
|
64
|
+
}, null, 2),
|
|
65
|
+
}],
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
server.tool(
|
|
74
|
+
"remove_volume",
|
|
75
|
+
"Remove a Docker volume. Use force=true to remove even if in use by containers.",
|
|
76
|
+
RemoveVolumeSchema.shape,
|
|
77
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
78
|
+
async (params) => {
|
|
79
|
+
try {
|
|
80
|
+
const volume = docker.getVolume(params.name);
|
|
81
|
+
await volume.remove({ force: params.force || false });
|
|
82
|
+
return {
|
|
83
|
+
content: [{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: JSON.stringify({ success: true, name: params.name, message: `Volume '${params.name}' removed` }),
|
|
86
|
+
}],
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
server.tool(
|
|
95
|
+
"prune_volumes",
|
|
96
|
+
"Remove all unused Docker volumes. Returns count of removed volumes and reclaimed space.",
|
|
97
|
+
PruneVolumesSchema.shape,
|
|
98
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
99
|
+
async (params) => {
|
|
100
|
+
try {
|
|
101
|
+
const filters: Record<string, string[]> = {};
|
|
102
|
+
if (params.filter) {
|
|
103
|
+
const match = params.filter.match(/label=(.+)/);
|
|
104
|
+
if (match) {
|
|
105
|
+
filters.label = [match[1]];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const result = await docker.pruneVolumes({
|
|
109
|
+
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
content: [{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: JSON.stringify({
|
|
115
|
+
volumes_deleted: result.VolumesDeleted || [],
|
|
116
|
+
space_reclaimed: result.SpaceReclaimed || 0,
|
|
117
|
+
}, null, 2),
|
|
118
|
+
}],
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -166,6 +166,26 @@ export const ListVolumesSchema = z.object({
|
|
|
166
166
|
filter: z.string().optional().describe("Filter by name or driver"),
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
export const CreateVolumeSchema = z.object({
|
|
170
|
+
name: z.string().min(1).max(255).describe("Volume name"),
|
|
171
|
+
driver: z.string().optional().describe("Volume driver (default: 'local')"),
|
|
172
|
+
labels: z.record(z.string(), z.string()).optional().describe("Labels to apply to the volume"),
|
|
173
|
+
options: z.record(z.string(), z.string()).optional().describe("Driver-specific options"),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export const InspectVolumeSchema = z.object({
|
|
177
|
+
name: z.string().min(1).describe("Volume name or ID to inspect"),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export const RemoveVolumeSchema = z.object({
|
|
181
|
+
name: z.string().min(1).describe("Volume name or ID to remove"),
|
|
182
|
+
force: z.boolean().optional().describe("Force removal even if in use (default: false)"),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export const PruneVolumesSchema = z.object({
|
|
186
|
+
filter: z.string().optional().describe("Filter by label (e.g., 'label=key=value')"),
|
|
187
|
+
});
|
|
188
|
+
|
|
169
189
|
|
|
170
190
|
// Monitoring schemas (v0.2.0)
|
|
171
191
|
export const ContainerHealthStatusSchema = z.object({});
|
package/tests/image.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { createDockerClient } from "../src/docker.js";
|
|
|
28
28
|
function createMockServer() {
|
|
29
29
|
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
30
30
|
return {
|
|
31
|
-
tool: (name: string, description: string, _schema: unknown, handler: Function) => {
|
|
31
|
+
tool: (name: string, description: string, _schema: unknown, _hints: unknown, handler: Function) => {
|
|
32
32
|
tools[name] = { description, handler };
|
|
33
33
|
},
|
|
34
34
|
tools,
|
package/tests/monitoring.test.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { registerMonitoringTools } from "../src/tools/monitoring.js";
|
|
|
27
27
|
function createMockServer() {
|
|
28
28
|
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
29
29
|
return {
|
|
30
|
-
tool: (name: string, description: string, _schema: unknown, handler: Function) => {
|
|
30
|
+
tool: (name: string, description: string, _schema: unknown, _hints: unknown, handler: Function) => {
|
|
31
31
|
tools[name] = { description, handler };
|
|
32
32
|
},
|
|
33
33
|
tools,
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock Dockerode before importing the module under test
|
|
4
|
+
const mockCreateVolume = vi.fn();
|
|
5
|
+
const mockListVolumes = vi.fn();
|
|
6
|
+
const mockPruneVolumes = vi.fn();
|
|
7
|
+
const mockGetVolume = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("dockerode", () => {
|
|
10
|
+
return {
|
|
11
|
+
default: vi.fn().mockImplementation(() => ({
|
|
12
|
+
createVolume: mockCreateVolume,
|
|
13
|
+
listVolumes: mockListVolumes,
|
|
14
|
+
pruneVolumes: mockPruneVolumes,
|
|
15
|
+
getVolume: mockGetVolume,
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { registerVolumeTools } from "../src/tools/volume.js";
|
|
21
|
+
|
|
22
|
+
// Minimal MCP server mock
|
|
23
|
+
function createMockServer() {
|
|
24
|
+
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
25
|
+
return {
|
|
26
|
+
tool: (
|
|
27
|
+
name: string,
|
|
28
|
+
description: string,
|
|
29
|
+
_schema: unknown,
|
|
30
|
+
_hints: unknown,
|
|
31
|
+
handler: Function
|
|
32
|
+
) => {
|
|
33
|
+
tools[name] = { description, handler };
|
|
34
|
+
},
|
|
35
|
+
tools,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("Volume Tools", () => {
|
|
40
|
+
let server: ReturnType<typeof createMockServer>;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
server = createMockServer();
|
|
45
|
+
// Create a fresh docker-like object with direct mock references
|
|
46
|
+
const docker = {
|
|
47
|
+
createVolume: mockCreateVolume,
|
|
48
|
+
listVolumes: mockListVolumes,
|
|
49
|
+
pruneVolumes: mockPruneVolumes,
|
|
50
|
+
getVolume: mockGetVolume,
|
|
51
|
+
} as any;
|
|
52
|
+
registerVolumeTools(server, docker);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("create_volume", () => {
|
|
56
|
+
it("should create a volume with default driver", async () => {
|
|
57
|
+
mockCreateVolume.mockResolvedValue({
|
|
58
|
+
Name: "test-vol",
|
|
59
|
+
Driver: "local",
|
|
60
|
+
Mountpoint: "/var/lib/docker/volumes/test-vol/_data",
|
|
61
|
+
CreatedAt: "2026-06-13T12:00:00Z",
|
|
62
|
+
Labels: {},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const handler = server.tools["create_volume"].handler;
|
|
66
|
+
const result = await handler({ name: "test-vol" });
|
|
67
|
+
|
|
68
|
+
expect(mockCreateVolume).toHaveBeenCalledWith({
|
|
69
|
+
Name: "test-vol",
|
|
70
|
+
Driver: "local",
|
|
71
|
+
Labels: undefined,
|
|
72
|
+
DriverOpts: undefined,
|
|
73
|
+
});
|
|
74
|
+
expect(result.isError).toBeFalsy();
|
|
75
|
+
const data = JSON.parse(result.content[0].text);
|
|
76
|
+
expect(data.name).toBe("test-vol");
|
|
77
|
+
expect(data.driver).toBe("local");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should create a volume with custom driver and labels", async () => {
|
|
81
|
+
mockCreateVolume.mockResolvedValue({
|
|
82
|
+
Name: "nfs-vol",
|
|
83
|
+
Driver: "nfs",
|
|
84
|
+
Mountpoint: "/var/lib/docker/volumes/nfs-vol/_data",
|
|
85
|
+
Labels: { env: "prod" },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const handler = server.tools["create_volume"].handler;
|
|
89
|
+
const result = await handler({
|
|
90
|
+
name: "nfs-vol",
|
|
91
|
+
driver: "nfs",
|
|
92
|
+
labels: { env: "prod" },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(mockCreateVolume).toHaveBeenCalledWith({
|
|
96
|
+
Name: "nfs-vol",
|
|
97
|
+
Driver: "nfs",
|
|
98
|
+
Labels: { env: "prod" },
|
|
99
|
+
DriverOpts: undefined,
|
|
100
|
+
});
|
|
101
|
+
expect(result.isError).toBeFalsy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should handle creation errors", async () => {
|
|
105
|
+
mockCreateVolume.mockRejectedValue(new Error("volume name already exists"));
|
|
106
|
+
|
|
107
|
+
const handler = server.tools["create_volume"].handler;
|
|
108
|
+
const result = await handler({ name: "existing-vol" });
|
|
109
|
+
|
|
110
|
+
expect(result.isError).toBe(true);
|
|
111
|
+
expect(result.content[0].text).toContain("volume name already exists");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("inspect_volume", () => {
|
|
116
|
+
it("should inspect a volume", async () => {
|
|
117
|
+
const mockInspect = vi.fn().mockResolvedValue({
|
|
118
|
+
Name: "test-vol",
|
|
119
|
+
Driver: "local",
|
|
120
|
+
Mountpoint: "/var/lib/docker/volumes/test-vol/_data",
|
|
121
|
+
Labels: {},
|
|
122
|
+
Scope: "local",
|
|
123
|
+
Options: {},
|
|
124
|
+
Status: null,
|
|
125
|
+
UsageData: null,
|
|
126
|
+
});
|
|
127
|
+
mockGetVolume.mockReturnValue({ inspect: mockInspect });
|
|
128
|
+
|
|
129
|
+
const handler = server.tools["inspect_volume"].handler;
|
|
130
|
+
const result = await handler({ name: "test-vol" });
|
|
131
|
+
|
|
132
|
+
expect(mockGetVolume).toHaveBeenCalledWith("test-vol");
|
|
133
|
+
expect(result.isError).toBeFalsy();
|
|
134
|
+
const data = JSON.parse(result.content[0].text);
|
|
135
|
+
expect(data.name).toBe("test-vol");
|
|
136
|
+
expect(data.scope).toBe("local");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should handle inspect errors for non-existent volumes", async () => {
|
|
140
|
+
const mockInspect = vi.fn().mockRejectedValue(new Error("No such volume"));
|
|
141
|
+
mockGetVolume.mockReturnValue({ inspect: mockInspect });
|
|
142
|
+
|
|
143
|
+
const handler = server.tools["inspect_volume"].handler;
|
|
144
|
+
const result = await handler({ name: "missing-vol" });
|
|
145
|
+
|
|
146
|
+
expect(result.isError).toBe(true);
|
|
147
|
+
expect(result.content[0].text).toContain("No such volume");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("remove_volume", () => {
|
|
152
|
+
it("should remove a volume without force", async () => {
|
|
153
|
+
const mockRemove = vi.fn().mockResolvedValue(undefined);
|
|
154
|
+
mockGetVolume.mockReturnValue({ remove: mockRemove });
|
|
155
|
+
|
|
156
|
+
const handler = server.tools["remove_volume"].handler;
|
|
157
|
+
const result = await handler({ name: "old-vol" });
|
|
158
|
+
|
|
159
|
+
expect(mockRemove).toHaveBeenCalledWith({ force: false });
|
|
160
|
+
expect(result.isError).toBeFalsy();
|
|
161
|
+
const data = JSON.parse(result.content[0].text);
|
|
162
|
+
expect(data.success).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should remove a volume with force", async () => {
|
|
166
|
+
const mockRemove = vi.fn().mockResolvedValue(undefined);
|
|
167
|
+
mockGetVolume.mockReturnValue({ remove: mockRemove });
|
|
168
|
+
|
|
169
|
+
const handler = server.tools["remove_volume"].handler;
|
|
170
|
+
const result = await handler({ name: "in-use-vol", force: true });
|
|
171
|
+
|
|
172
|
+
expect(mockRemove).toHaveBeenCalledWith({ force: true });
|
|
173
|
+
expect(result.isError).toBeFalsy();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should handle removal errors", async () => {
|
|
177
|
+
const mockRemove = vi.fn().mockRejectedValue(new Error("volume is in use"));
|
|
178
|
+
mockGetVolume.mockReturnValue({ remove: mockRemove });
|
|
179
|
+
|
|
180
|
+
const handler = server.tools["remove_volume"].handler;
|
|
181
|
+
const result = await handler({ name: "busy-vol" });
|
|
182
|
+
|
|
183
|
+
expect(result.isError).toBe(true);
|
|
184
|
+
expect(result.content[0].text).toContain("volume is in use");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("prune_volumes", () => {
|
|
189
|
+
it("should prune unused volumes", async () => {
|
|
190
|
+
mockPruneVolumes.mockResolvedValue({
|
|
191
|
+
VolumesDeleted: ["vol1", "vol2"],
|
|
192
|
+
SpaceReclaimed: 1024000,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const handler = server.tools["prune_volumes"].handler;
|
|
196
|
+
const result = await handler({});
|
|
197
|
+
|
|
198
|
+
expect(mockPruneVolumes).toHaveBeenCalledWith({ filters: undefined });
|
|
199
|
+
expect(result.isError).toBeFalsy();
|
|
200
|
+
const data = JSON.parse(result.content[0].text);
|
|
201
|
+
expect(data.volumes_deleted).toEqual(["vol1", "vol2"]);
|
|
202
|
+
expect(data.space_reclaimed).toBe(1024000);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should prune with label filter", async () => {
|
|
206
|
+
mockPruneVolumes.mockResolvedValue({
|
|
207
|
+
VolumesDeleted: ["old-cache"],
|
|
208
|
+
SpaceReclaimed: 500000,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const handler = server.tools["prune_volumes"].handler;
|
|
212
|
+
const result = await handler({ filter: "label=env=test" });
|
|
213
|
+
|
|
214
|
+
expect(mockPruneVolumes).toHaveBeenCalledWith({
|
|
215
|
+
filters: { label: ["env=test"] },
|
|
216
|
+
});
|
|
217
|
+
expect(result.isError).toBeFalsy();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should handle prune errors", async () => {
|
|
221
|
+
mockPruneVolumes.mockRejectedValue(new Error("prune failed"));
|
|
222
|
+
|
|
223
|
+
const handler = server.tools["prune_volumes"].handler;
|
|
224
|
+
const result = await handler({});
|
|
225
|
+
|
|
226
|
+
expect(result.isError).toBe(true);
|
|
227
|
+
expect(result.content[0].text).toContain("prune failed");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|