@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 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(/[\uE0001-\uE007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, "");
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
- const server = createServer(docker);
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 declare function createServer(docker: Dockerode): McpServer;
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.2.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,4 @@
1
+ import Dockerode from "dockerode";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ export declare function registerVolumeTools(server: McpServer, docker: Dockerode): void;
4
+ //# sourceMappingURL=volume.d.ts.map
@@ -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.2.5",
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(/[\uE0001-\uE007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, "");
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
- const server = createServer(docker);
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 function createServer(docker: Dockerode): McpServer {
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.2.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({});
@@ -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,
@@ -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
+ });