@supernova123/docker-mcp-server 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docker.js +1 -1
- package/dist/server.js +3 -1
- 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 +1 -1
- package/src/docker.ts.bak +85 -0
- package/src/server.ts +3 -1
- package/src/tools/monitoring.ts.bak +376 -0
- 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.js
CHANGED
|
@@ -57,7 +57,7 @@ export function sanitizeOutput(text, maxLength = 1_000_000) {
|
|
|
57
57
|
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
58
58
|
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
59
59
|
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
60
|
-
text = text.replace(/[\
|
|
60
|
+
text = text.replace(/[\u{E0000}-\u{E007F}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu, "");
|
|
61
61
|
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
62
62
|
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
63
63
|
// Truncate to cap memory and LLM context cost
|
package/dist/server.js
CHANGED
|
@@ -6,11 +6,12 @@ import { registerHealthTools } from "./tools/health.js";
|
|
|
6
6
|
import { registerLogsTools } from "./tools/logs.js";
|
|
7
7
|
import { registerExecTools } from "./tools/exec.js";
|
|
8
8
|
import { registerNetworkTools } from "./tools/network.js";
|
|
9
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
9
10
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
10
11
|
export function createServer(docker) {
|
|
11
12
|
const server = new McpServer({
|
|
12
13
|
name: "docker-mcp-server",
|
|
13
|
-
version: "0.
|
|
14
|
+
version: "0.3.0",
|
|
14
15
|
});
|
|
15
16
|
// Register all tool categories
|
|
16
17
|
registerContainerTools(server, docker);
|
|
@@ -20,6 +21,7 @@ export function createServer(docker) {
|
|
|
20
21
|
registerLogsTools(server, docker);
|
|
21
22
|
registerExecTools(server, docker);
|
|
22
23
|
registerNetworkTools(server, docker);
|
|
24
|
+
registerVolumeTools(server, docker);
|
|
23
25
|
registerMonitoringTools(server, docker);
|
|
24
26
|
return server;
|
|
25
27
|
}
|
|
@@ -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.0",
|
|
4
4
|
"mcpName": "io.github.friendlygeorge/docker-mcp-server",
|
|
5
5
|
"description": "MCP server for Docker — container management, health checks, auto-restart, Compose lifecycle, and log streaming for Claude, Cursor, and AI agents",
|
|
6
6
|
"type": "module",
|
package/src/docker.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function sanitizeOutput(text: string, maxLength = 1_000_000): string {
|
|
|
66
66
|
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
67
67
|
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
68
68
|
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
69
|
-
text = text.replace(/[\
|
|
69
|
+
text = text.replace(/[\u{E0000}-\u{E007F}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu, "");
|
|
70
70
|
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
71
71
|
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
72
72
|
// Truncate to cap memory and LLM context cost
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
|
|
3
|
+
export interface DockerClientOptions {
|
|
4
|
+
socketPath?: string;
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createDockerClient(options?: DockerClientOptions): Dockerode {
|
|
10
|
+
if (options?.socketPath) {
|
|
11
|
+
return new Dockerode({ socketPath: options.socketPath });
|
|
12
|
+
}
|
|
13
|
+
if (options?.host && options?.port) {
|
|
14
|
+
return new Dockerode({ host: options.host, port: options.port });
|
|
15
|
+
}
|
|
16
|
+
// Default: local socket
|
|
17
|
+
return new Dockerode({ socketPath: "/var/run/docker.sock" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatError(error: unknown): string {
|
|
21
|
+
if (error instanceof Error) return error.message;
|
|
22
|
+
if (typeof error === "string") return error;
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatContainer(container: Dockerode.ContainerInfo): Record<string, unknown> {
|
|
27
|
+
return {
|
|
28
|
+
id: container.Id.substring(0, 12),
|
|
29
|
+
name: container.Names[0]?.replace(/^\//, ""),
|
|
30
|
+
image: container.Image,
|
|
31
|
+
state: container.State,
|
|
32
|
+
status: container.Status,
|
|
33
|
+
created: new Date(container.Created * 1000).toISOString(),
|
|
34
|
+
ports: container.Ports.map((p) => ({
|
|
35
|
+
private: p.PrivatePort,
|
|
36
|
+
public: p.PublicPort,
|
|
37
|
+
type: p.Type,
|
|
38
|
+
})),
|
|
39
|
+
labels: container.Labels,
|
|
40
|
+
mounts: container.Mounts.map((m) => ({
|
|
41
|
+
type: m.Type,
|
|
42
|
+
source: m.Source,
|
|
43
|
+
destination: m.Destination,
|
|
44
|
+
mode: m.Mode,
|
|
45
|
+
rw: m.RW,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatImage(image: Dockerode.ImageInfo): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
id: image.Id.substring(0, 19),
|
|
53
|
+
tags: image.RepoTags || ["<none>:<none>"],
|
|
54
|
+
size: image.Size,
|
|
55
|
+
created: new Date(image.Created).toISOString(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
|
|
61
|
+
* Prevents prompt injection via output and caps LLM context cost.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeOutput(text: string, maxLength = 1_000_000): string {
|
|
64
|
+
// Strip ANSI escape codes (CSI, OSC, simple sequences)
|
|
65
|
+
text = text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
66
|
+
text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
|
|
67
|
+
text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
|
|
68
|
+
// Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
|
|
69
|
+
text = text.replace(/[\uE0001-\uE007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, "");
|
|
70
|
+
// Strip Docker stream-frame headers (8-byte prefix per frame)
|
|
71
|
+
text = text.replace(/^[\x00-\x0f]{8}/gm, "");
|
|
72
|
+
// Truncate to cap memory and LLM context cost
|
|
73
|
+
if (text.length > maxLength) {
|
|
74
|
+
return text.slice(0, maxLength) + `\n... [output truncated at ${maxLength} chars]`;
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatBytes(bytes: number): string {
|
|
80
|
+
if (bytes === 0) return "0 B";
|
|
81
|
+
const k = 1024;
|
|
82
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
83
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
84
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
85
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -7,12 +7,13 @@ import { registerHealthTools } from "./tools/health.js";
|
|
|
7
7
|
import { registerLogsTools } from "./tools/logs.js";
|
|
8
8
|
import { registerExecTools } from "./tools/exec.js";
|
|
9
9
|
import { registerNetworkTools } from "./tools/network.js";
|
|
10
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
10
11
|
import { registerMonitoringTools } from "./tools/monitoring.js";
|
|
11
12
|
|
|
12
13
|
export function createServer(docker: Dockerode): McpServer {
|
|
13
14
|
const server = new McpServer({
|
|
14
15
|
name: "docker-mcp-server",
|
|
15
|
-
version: "0.
|
|
16
|
+
version: "0.3.0",
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
// Register all tool categories
|
|
@@ -23,6 +24,7 @@ export function createServer(docker: Dockerode): McpServer {
|
|
|
23
24
|
registerLogsTools(server, docker);
|
|
24
25
|
registerExecTools(server, docker);
|
|
25
26
|
registerNetworkTools(server, docker);
|
|
27
|
+
registerVolumeTools(server, docker);
|
|
26
28
|
registerMonitoringTools(server, docker);
|
|
27
29
|
|
|
28
30
|
return server;
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import Dockerode from "dockerode";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import {
|
|
4
|
+
ContainerHealthStatusSchema,
|
|
5
|
+
ContainerResourceUsageSchema,
|
|
6
|
+
WatchEventsSchema,
|
|
7
|
+
SearchLogsSchema,
|
|
8
|
+
ResourceAlertCheckSchema,
|
|
9
|
+
MonitorDashboardSchema,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
import { sanitizeOutput } from "../docker.js";
|
|
12
|
+
|
|
13
|
+
export function registerMonitoringTools(server: McpServer, docker: Dockerode): void {
|
|
14
|
+
// 1. fleet_status — health status of all running containers
|
|
15
|
+
server.tool(
|
|
16
|
+
"container_health_status",
|
|
17
|
+
"Check health status, uptime, and restart count for all running Docker containers. Returns JSON with container name, state, health probe status (healthy/unhealthy/no-healthcheck), and restart count. Use this for a quick fleet health overview; for resource metrics use container_resource_usage instead. Returns an array of objects with name, id, state, status, health, uptime, restartCount, and image fields. Read-only and safe to call repeatedly.",
|
|
18
|
+
ContainerHealthStatusSchema.shape,
|
|
19
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
20
|
+
async (params) => {
|
|
21
|
+
try {
|
|
22
|
+
const containers = await docker.listContainers({ all: false });
|
|
23
|
+
const results = await Promise.all(
|
|
24
|
+
containers.map(async (c) => {
|
|
25
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
26
|
+
return {
|
|
27
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
28
|
+
id: c.Id.slice(0, 12),
|
|
29
|
+
state: c.State,
|
|
30
|
+
status: c.Status,
|
|
31
|
+
health: info.State.Health?.Status || "no-healthcheck",
|
|
32
|
+
uptime: info.State.StartedAt,
|
|
33
|
+
restartCount: info.RestartCount,
|
|
34
|
+
image: c.Image,
|
|
35
|
+
};
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
40
|
+
};
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
44
|
+
isError: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// 2. fleet_stats — resource usage for all running containers
|
|
51
|
+
server.tool(
|
|
52
|
+
"container_resource_usage",
|
|
53
|
+
"Monitor CPU, memory, and network I/O across all running Docker containers. Returns sorted resource usage metrics with percentage breakdowns for each container. Use container_health_status for health probes; use resource_alert_check for threshold violations. Supports sort by cpu, memory, or network. Returns array of objects with name, id, cpu_percent, memory_usage_mb, memory_percent, network_rx_mb, network_tx_mb. Read-only and safe to call repeatedly.",
|
|
54
|
+
ContainerResourceUsageSchema.shape,
|
|
55
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
56
|
+
async (params) => {
|
|
57
|
+
try {
|
|
58
|
+
const containers = await docker.listContainers({ all: false });
|
|
59
|
+
const results = await Promise.all(
|
|
60
|
+
containers.map(async (c) => {
|
|
61
|
+
const stats = await docker.getContainer(c.Id).stats({ stream: false });
|
|
62
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
63
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
64
|
+
const cpuCount = stats.cpu_stats.online_cpus ?? 1;
|
|
65
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
66
|
+
const memUsage = stats.memory_stats?.usage ?? 0;
|
|
67
|
+
const memLimit = stats.memory_stats?.limit ?? 1;
|
|
68
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
69
|
+
const netRx = Object.values(stats.networks ?? {}).reduce((sum: number, n: any) => sum + (n.rx_bytes ?? 0), 0);
|
|
70
|
+
const netTx = Object.values(stats.networks ?? {}).reduce((sum: number, n: any) => sum + (n.tx_bytes ?? 0), 0);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
74
|
+
id: c.Id.slice(0, 12),
|
|
75
|
+
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
|
76
|
+
memory_usage_mb: Math.round((memUsage / 1024 / 1024) * 100) / 100,
|
|
77
|
+
memory_percent: Math.round(memPercent * 100) / 100,
|
|
78
|
+
network_rx_mb: Math.round((netRx / 1024 / 1024) * 100) / 100,
|
|
79
|
+
network_tx_mb: Math.round((netTx / 1024 / 1024) * 100) / 100,
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const sortBy = params.sort_by || "cpu";
|
|
85
|
+
results.sort((a: any, b: any) => {
|
|
86
|
+
if (sortBy === "cpu") return b.cpu_percent - a.cpu_percent;
|
|
87
|
+
if (sortBy === "memory") return b.memory_percent - a.memory_percent;
|
|
88
|
+
return (b.network_rx_mb + b.network_tx_mb) - (a.network_rx_mb + a.network_tx_mb);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
93
|
+
};
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// 3. watch_events — stream Docker events (simplified: collect events for a duration)
|
|
104
|
+
server.tool(
|
|
105
|
+
"watch_events",
|
|
106
|
+
"Stream Docker container events (start, stop, die, restart, health_status) over a configurable time window. Filter by specific container or event type. Use container_health_status for current state; use this tool to watch for changes over time. Returns array of event objects with type, action, container, and time fields. Returns 'No events captured in the time window.' when no events occur. Read-only and safe to call repeatedly.",
|
|
107
|
+
WatchEventsSchema.shape,
|
|
108
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
109
|
+
async (params) => {
|
|
110
|
+
try {
|
|
111
|
+
const durationMs = (params.duration || 30) * 1000;
|
|
112
|
+
const filter: any = {};
|
|
113
|
+
if (params.container) filter.container = [params.container];
|
|
114
|
+
if (params.event_type && params.event_type !== "all") filter.event = [params.event_type];
|
|
115
|
+
if (params.since) filter.since = [params.since];
|
|
116
|
+
|
|
117
|
+
const events: any[] = [];
|
|
118
|
+
const stream = await docker.getEvents(filter as Dockerode.GetEventsOptions) as unknown as NodeJS.ReadableStream;
|
|
119
|
+
|
|
120
|
+
await new Promise<void>((resolve) => {
|
|
121
|
+
const timeout = setTimeout(() => {
|
|
122
|
+
resolve();
|
|
123
|
+
}, durationMs);
|
|
124
|
+
|
|
125
|
+
stream.on("data", (chunk: Buffer) => {
|
|
126
|
+
try {
|
|
127
|
+
const event = JSON.parse(chunk.toString());
|
|
128
|
+
events.push({
|
|
129
|
+
type: event.Type,
|
|
130
|
+
action: event.Action,
|
|
131
|
+
container: event.Actor?.Attributes?.name || event.Actor?.ID?.slice(0, 12),
|
|
132
|
+
time: new Date(event.time * 1000).toISOString(),
|
|
133
|
+
});
|
|
134
|
+
} catch {}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
stream.on("error", () => {
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
resolve();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
stream.on("end", () => {
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text", text: events.length ? JSON.stringify(events, null, 2) : "No events captured in the time window." }],
|
|
150
|
+
};
|
|
151
|
+
} catch (err: any) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// 4. search_logs — search logs across multiple containers
|
|
161
|
+
server.tool(
|
|
162
|
+
"search_logs",
|
|
163
|
+
"Search Docker container logs across multiple containers using regex pattern matching. Use stream_logs for single-container log tailing; use this tool to search across multiple containers at once. Returns matching log lines with container name and line content. The pattern parameter accepts any valid regex; set ignore_case for case-insensitive matching. Returns 'No matches found.' when no lines match. Read-only and safe to call repeatedly.",
|
|
164
|
+
SearchLogsSchema.shape,
|
|
165
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
166
|
+
async (params) => {
|
|
167
|
+
try {
|
|
168
|
+
const targetContainers = params.containers || [];
|
|
169
|
+
let containers: { id: string; name: string }[];
|
|
170
|
+
|
|
171
|
+
if (targetContainers.length > 0) {
|
|
172
|
+
containers = await Promise.all(
|
|
173
|
+
targetContainers.map(async (id) => {
|
|
174
|
+
const info = await docker.getContainer(id).inspect();
|
|
175
|
+
return { id, name: info.Name.replace(/^\//, "") };
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
const list = await docker.listContainers({ all: false });
|
|
180
|
+
containers = list.map((c) => ({ id: c.Id, name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12) }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const regex = new RegExp(params.pattern, params.ignore_case ? "i" : "");
|
|
184
|
+
const matches: any[] = [];
|
|
185
|
+
|
|
186
|
+
for (const container of containers) {
|
|
187
|
+
try {
|
|
188
|
+
const logStream = await docker.getContainer(container.id).logs({
|
|
189
|
+
stdout: true,
|
|
190
|
+
stderr: true,
|
|
191
|
+
tail: params.tail || 500,
|
|
192
|
+
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
193
|
+
});
|
|
194
|
+
const output = sanitizeOutput(logStream.toString("utf-8"), 100_000);
|
|
195
|
+
const lines = output.split("\n");
|
|
196
|
+
for (const line of lines) {
|
|
197
|
+
if (regex.test(line)) {
|
|
198
|
+
matches.push({ container: container.name, line: line.trim() });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
content: [{ type: "text", text: matches.length ? JSON.stringify(matches, null, 2) : "No matches found." }],
|
|
206
|
+
};
|
|
207
|
+
} catch (err: any) {
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
210
|
+
isError: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// 5. check_thresholds — check all containers against thresholds
|
|
217
|
+
server.tool(
|
|
218
|
+
"resource_alert_check",
|
|
219
|
+
"Check all running Docker containers against configurable CPU%, memory%, and restart count thresholds. Returns containers that violate thresholds with specific metrics that triggered alerts. Use container_resource_usage for raw metrics; use this tool for automated alerting. Default thresholds: 80% CPU, 80% memory, 5 restarts. Returns { violations: [...], checked: N } or { message: 'All containers within thresholds.', checked: N }. Read-only and safe to call repeatedly.",
|
|
220
|
+
ResourceAlertCheckSchema.shape,
|
|
221
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
222
|
+
async (params) => {
|
|
223
|
+
try {
|
|
224
|
+
const cpuThreshold = params.cpu_percent ?? 80;
|
|
225
|
+
const memThreshold = params.memory_percent ?? 80;
|
|
226
|
+
const restartThreshold = params.restart_count ?? 5;
|
|
227
|
+
const containers = await docker.listContainers({ all: false });
|
|
228
|
+
const violations: any[] = [];
|
|
229
|
+
|
|
230
|
+
for (const c of containers) {
|
|
231
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
232
|
+
const issues: string[] = [];
|
|
233
|
+
|
|
234
|
+
// Check restart count
|
|
235
|
+
if (info.RestartCount > restartThreshold) {
|
|
236
|
+
issues.push(`restarts: ${info.RestartCount} > ${restartThreshold}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check CPU and memory
|
|
240
|
+
try {
|
|
241
|
+
const stats = await docker.getContainer(c.Id).stats({ stream: false });
|
|
242
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
243
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
244
|
+
const cpuCount = stats.cpu_stats.online_cpus ?? 1;
|
|
245
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
246
|
+
const memUsage = stats.memory_stats?.usage ?? 0;
|
|
247
|
+
const memLimit = stats.memory_stats?.limit ?? 1;
|
|
248
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
249
|
+
|
|
250
|
+
if (cpuPercent > cpuThreshold) issues.push(`cpu: ${Math.round(cpuPercent)}% > ${cpuThreshold}%`);
|
|
251
|
+
if (memPercent > memThreshold) issues.push(`memory: ${Math.round(memPercent)}% > ${memThreshold}%`);
|
|
252
|
+
} catch {}
|
|
253
|
+
|
|
254
|
+
if (issues.length > 0) {
|
|
255
|
+
violations.push({
|
|
256
|
+
container: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
257
|
+
id: c.Id.slice(0, 12),
|
|
258
|
+
issues,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
content: [{
|
|
265
|
+
type: "text",
|
|
266
|
+
text: violations.length
|
|
267
|
+
? JSON.stringify({ violations, checked: containers.length }, null, 2)
|
|
268
|
+
: JSON.stringify({ message: "All containers within thresholds.", checked: containers.length }),
|
|
269
|
+
}],
|
|
270
|
+
};
|
|
271
|
+
} catch (err: any) {
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
274
|
+
isError: true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// 6. monitor_dashboard — single-call fleet summary
|
|
281
|
+
server.tool(
|
|
282
|
+
"monitor_dashboard",
|
|
283
|
+
"Comprehensive Docker fleet dashboard in a single API call. Aggregates health status of all containers, top 5 CPU consumers, recent events (last 5 minutes), and threshold violations into one response. Use individual tools (container_health_status, container_resource_usage, watch_events) for targeted queries; use this for a complete fleet overview. Returns object with summary (total, running, healthy, unhealthy), top_consumers, recent_events, and violations. Read-only and safe to call repeatedly.",
|
|
284
|
+
MonitorDashboardSchema.shape,
|
|
285
|
+
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
286
|
+
async (params) => {
|
|
287
|
+
try {
|
|
288
|
+
const containers = await docker.listContainers({ all: false });
|
|
289
|
+
|
|
290
|
+
// Fleet health
|
|
291
|
+
const health = await Promise.all(
|
|
292
|
+
containers.map(async (c) => {
|
|
293
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
294
|
+
return {
|
|
295
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
296
|
+
state: c.State,
|
|
297
|
+
health: info.State.Health?.Status || "no-healthcheck",
|
|
298
|
+
restartCount: info.RestartCount,
|
|
299
|
+
};
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Resource usage (top 5 by CPU)
|
|
304
|
+
const stats = await Promise.all(
|
|
305
|
+
containers.map(async (c) => {
|
|
306
|
+
try {
|
|
307
|
+
const s = await docker.getContainer(c.Id).stats({ stream: false });
|
|
308
|
+
const cpuDelta = s.cpu_stats.cpu_usage.total_usage - (s.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
309
|
+
const systemDelta = s.cpu_stats.system_cpu_usage - (s.precpu_stats?.system_cpu_usage ?? 0);
|
|
310
|
+
const cpuCount = s.cpu_stats.online_cpus ?? 1;
|
|
311
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
312
|
+
const memUsage = s.memory_stats?.usage ?? 0;
|
|
313
|
+
const memLimit = s.memory_stats?.limit ?? 1;
|
|
314
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
315
|
+
return {
|
|
316
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
317
|
+
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
|
318
|
+
memory_percent: Math.round(memPercent * 100) / 100,
|
|
319
|
+
};
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const topConsumers = stats.filter(Boolean).sort((a: any, b: any) => b.cpu_percent - a.cpu_percent).slice(0, 5);
|
|
327
|
+
|
|
328
|
+
// Recent events (last 5 minutes) - use simple approach
|
|
329
|
+
const recentEvents: any[] = [];
|
|
330
|
+
try {
|
|
331
|
+
const sinceTs = Math.floor((Date.now() - 5 * 60 * 1000) / 1000);
|
|
332
|
+
const eventStream = await docker.getEvents({ since: sinceTs }) as unknown as NodeJS.ReadableStream;
|
|
333
|
+
await new Promise<void>((resolve) => {
|
|
334
|
+
const timeout = setTimeout(() => { resolve(); }, 2000);
|
|
335
|
+
eventStream.on("data", (chunk: Buffer) => {
|
|
336
|
+
try {
|
|
337
|
+
const e = JSON.parse(chunk.toString());
|
|
338
|
+
recentEvents.push({
|
|
339
|
+
action: e.Action,
|
|
340
|
+
container: e.Actor?.Attributes?.name || e.Actor?.ID?.slice(0, 12),
|
|
341
|
+
time: new Date(e.time * 1000).toISOString(),
|
|
342
|
+
});
|
|
343
|
+
} catch {}
|
|
344
|
+
});
|
|
345
|
+
eventStream.on("error", () => { clearTimeout(timeout); resolve(); });
|
|
346
|
+
eventStream.on("end", () => { clearTimeout(timeout); resolve(); });
|
|
347
|
+
});
|
|
348
|
+
} catch {}
|
|
349
|
+
|
|
350
|
+
// Threshold violations
|
|
351
|
+
const violations = stats.filter(Boolean).filter((s: any) => s.cpu_percent > 80 || s.memory_percent > 80);
|
|
352
|
+
|
|
353
|
+
const dashboard = {
|
|
354
|
+
summary: {
|
|
355
|
+
total_containers: containers.length,
|
|
356
|
+
running: containers.filter((c) => c.State === "running").length,
|
|
357
|
+
unhealthy: health.filter((h) => h.health === "unhealthy").length,
|
|
358
|
+
},
|
|
359
|
+
health,
|
|
360
|
+
top_cpu_consumers: topConsumers,
|
|
361
|
+
recent_events: recentEvents.slice(0, 10),
|
|
362
|
+
threshold_violations: violations,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: JSON.stringify(dashboard, null, 2) }],
|
|
367
|
+
};
|
|
368
|
+
} catch (err: any) {
|
|
369
|
+
return {
|
|
370
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
371
|
+
isError: true,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
);
|
|
376
|
+
}
|
|
@@ -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
|
+
});
|