@supernova123/docker-mcp-server 0.1.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.
@@ -0,0 +1,150 @@
1
+ import Dockerode from "dockerode";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { CheckHealthSchema, WatchHealthSchema, SetRestartPolicySchema } from "../types.js";
4
+ import { formatError } from "../docker.js";
5
+
6
+ export function registerHealthTools(server: McpServer, docker: Dockerode): void {
7
+ server.tool(
8
+ "check_health",
9
+ "Run a health probe against a container. Supports HTTP, TCP, and exec probes. Auto-detects from container HEALTHCHECK if available.",
10
+ CheckHealthSchema.shape,
11
+ async (params) => {
12
+ try {
13
+ const container = docker.getContainer(params.container_id);
14
+ const info = await container.inspect();
15
+
16
+ // Check if container has HEALTHCHECK
17
+ const healthcheck = info.Config.Healthcheck;
18
+ let probeType = params.type;
19
+ let endpoint = params.endpoint;
20
+ let command = params.command;
21
+
22
+ if (!probeType && healthcheck?.Test?.length) {
23
+ const test = healthcheck.Test[0];
24
+ if (test.startsWith("CMD ")) {
25
+ probeType = "exec";
26
+ command = test.slice(4).split(" ");
27
+ } else if (test.startsWith("CMD-SHELL ")) {
28
+ probeType = "exec";
29
+ command = ["sh", "-c", test.slice(10)];
30
+ } else if (test === "NONE") {
31
+ probeType = undefined;
32
+ }
33
+ }
34
+
35
+ if (!probeType) {
36
+ return {
37
+ content: [{ type: "text", text: "No health check configured for this container and no probe type specified." }],
38
+ };
39
+ }
40
+
41
+ if (probeType === "exec" && command) {
42
+ const exec = await container.exec({
43
+ Cmd: command,
44
+ AttachStdout: true,
45
+ AttachStderr: true,
46
+ });
47
+ const stream = await exec.start({}) as unknown as import("stream").Duplex;
48
+ const output = await new Promise<string>((resolve) => {
49
+ let data = "";
50
+ stream.on("data", (chunk: Buffer) => { data += chunk.toString(); });
51
+ stream.on("end", () => resolve(data));
52
+ });
53
+ const inspect = await exec.inspect();
54
+ return {
55
+ content: [{
56
+ type: "text",
57
+ text: JSON.stringify({
58
+ healthy: inspect.ExitCode === 0,
59
+ exitCode: inspect.ExitCode,
60
+ output: output.trim(),
61
+ }, null, 2),
62
+ }],
63
+ };
64
+ }
65
+
66
+ return {
67
+ content: [{ type: "text", text: `Health probe type '${probeType}' is not yet implemented in v1. Use exec probes or check container HEALTHCHECK directly.` }],
68
+ isError: true,
69
+ };
70
+ } catch (error) {
71
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
72
+ }
73
+ }
74
+ );
75
+
76
+ server.tool(
77
+ "watch_health",
78
+ "Poll a container's health status until it becomes healthy or times out. Useful for waiting on service startup.",
79
+ WatchHealthSchema.shape,
80
+ async (params) => {
81
+ try {
82
+ const container = docker.getContainer(params.container_id);
83
+ const timeout = (params.timeout ?? 60) * 1000;
84
+ const interval = (params.interval ?? 5) * 1000;
85
+ const start = Date.now();
86
+
87
+ while (Date.now() - start < timeout) {
88
+ const info = await container.inspect();
89
+ const health = info.State.Health;
90
+ if (health?.Status === "healthy") {
91
+ return {
92
+ content: [{ type: "text", text: JSON.stringify({ healthy: true, status: "healthy", waitTime: Date.now() - start }, null, 2) }],
93
+ };
94
+ }
95
+ if (health?.Status === "unhealthy") {
96
+ const lastLog = health.Log?.[health.Log.length - 1];
97
+ return {
98
+ content: [{
99
+ type: "text",
100
+ text: JSON.stringify({
101
+ healthy: false,
102
+ status: "unhealthy",
103
+ exitCode: lastLog?.ExitCode,
104
+ output: lastLog?.Output?.trim(),
105
+ }, null, 2),
106
+ }],
107
+ };
108
+ }
109
+ await new Promise((resolve) => setTimeout(resolve, interval));
110
+ }
111
+
112
+ const info = await container.inspect();
113
+ return {
114
+ content: [{
115
+ type: "text",
116
+ text: JSON.stringify({
117
+ healthy: false,
118
+ status: "timeout",
119
+ containerHealth: info.State.Health?.Status ?? "no healthcheck",
120
+ }, null, 2),
121
+ }],
122
+ };
123
+ } catch (error) {
124
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
125
+ }
126
+ }
127
+ );
128
+
129
+ server.tool(
130
+ "set_restart_policy",
131
+ "Change the restart policy of a running container without recreating it.",
132
+ SetRestartPolicySchema.shape,
133
+ async (params) => {
134
+ try {
135
+ const container = docker.getContainer(params.container_id);
136
+ await container.update({
137
+ RestartPolicy: {
138
+ Name: params.policy,
139
+ MaximumRetryCount: params.max_retry_count ?? 0,
140
+ },
141
+ });
142
+ return {
143
+ content: [{ type: "text", text: `Restart policy set to '${params.policy}' for container ${params.container_id}.` }],
144
+ };
145
+ } catch (error) {
146
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
147
+ }
148
+ }
149
+ );
150
+ }
@@ -0,0 +1,92 @@
1
+ import Dockerode from "dockerode";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import {
4
+ ListImagesSchema,
5
+ PullImageSchema,
6
+ BuildImageSchema,
7
+ RemoveImageSchema,
8
+ } from "../types.js";
9
+ import { formatImage, formatError } from "../docker.js";
10
+
11
+ export function registerImageTools(server: McpServer, docker: Dockerode): void {
12
+ server.tool(
13
+ "list_images",
14
+ "List Docker images with optional filters. Returns image IDs, tags, sizes, and creation dates.",
15
+ ListImagesSchema.shape,
16
+ async (params) => {
17
+ try {
18
+ const images = await docker.listImages({
19
+ all: params.all ?? false,
20
+ filters: params.filter ? JSON.stringify({ reference: [params.filter] }) : undefined,
21
+ });
22
+ const results = images.map(formatImage);
23
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
24
+ } catch (error) {
25
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
26
+ }
27
+ }
28
+ );
29
+
30
+ server.tool(
31
+ "pull_image",
32
+ "Pull a Docker image from a registry. Returns pull progress events.",
33
+ PullImageSchema.shape,
34
+ async (params) => {
35
+ try {
36
+ const imageRef = params.tag ? `${params.image}:${params.tag}` : params.image;
37
+ const stream = await docker.pull(imageRef);
38
+ // Wait for pull to complete
39
+ await new Promise<void>((resolve, reject) => {
40
+ docker.modem.followProgress(stream, (err: Error | null) => {
41
+ if (err) reject(err);
42
+ else resolve();
43
+ });
44
+ });
45
+ return { content: [{ type: "text", text: `Image ${imageRef} pulled successfully.` }] };
46
+ } catch (error) {
47
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
48
+ }
49
+ }
50
+ );
51
+
52
+ server.tool(
53
+ "build_image",
54
+ "Build a Docker image from a Dockerfile or build context path.",
55
+ BuildImageSchema.shape,
56
+ async (params) => {
57
+ try {
58
+ const stream = await docker.buildImage(
59
+ {
60
+ context: params.context,
61
+ src: [params.dockerfile ?? "Dockerfile"],
62
+ },
63
+ { t: params.tag, dockerfile: params.dockerfile, buildargs: params.build_args, target: params.target }
64
+ );
65
+ await new Promise<void>((resolve, reject) => {
66
+ docker.modem.followProgress(stream, (err: Error | null) => {
67
+ if (err) reject(err);
68
+ else resolve();
69
+ });
70
+ });
71
+ return { content: [{ type: "text", text: `Image ${params.tag} built successfully.` }] };
72
+ } catch (error) {
73
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
74
+ }
75
+ }
76
+ );
77
+
78
+ server.tool(
79
+ "remove_image",
80
+ "Remove a Docker image by name or ID. Use force to remove even if tagged.",
81
+ RemoveImageSchema.shape,
82
+ async (params) => {
83
+ try {
84
+ const image = docker.getImage(params.image);
85
+ await image.remove({ force: params.force ?? false });
86
+ return { content: [{ type: "text", text: `Image ${params.image} removed.` }] };
87
+ } catch (error) {
88
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
89
+ }
90
+ }
91
+ );
92
+ }
@@ -0,0 +1,85 @@
1
+ import Dockerode from "dockerode";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
4
+ import { formatError, formatBytes } from "../docker.js";
5
+
6
+ export function registerLogsTools(server: McpServer, docker: Dockerode): void {
7
+ server.tool(
8
+ "stream_logs",
9
+ "Get logs from a Docker container. Supports tail count, timestamp filtering, and follow mode.",
10
+ StreamLogsSchema.shape,
11
+ async (params) => {
12
+ try {
13
+ const container = docker.getContainer(params.container_id);
14
+ const logs = await container.logs({
15
+ stdout: true,
16
+ stderr: true,
17
+ tail: params.tail ?? 100,
18
+ since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
19
+ follow: false as const,
20
+ });
21
+ // Dockerode returns a Buffer with multiplexed stream headers
22
+ const output = logs.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
23
+ return { content: [{ type: "text", text: output || "No logs found." }] };
24
+ } catch (error) {
25
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
26
+ }
27
+ }
28
+ );
29
+
30
+ server.tool(
31
+ "container_stats",
32
+ "Get real-time resource usage statistics for a Docker container (CPU, memory, network, I/O).",
33
+ ContainerStatsSchema.shape,
34
+ async (params) => {
35
+ try {
36
+ const container = docker.getContainer(params.container_id);
37
+ const stats = await container.stats({ stream: false });
38
+
39
+ const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
40
+ const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
41
+ const cpuCount = stats.cpu_stats.online_cpus ?? 1;
42
+ const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
43
+
44
+ const memUsage = stats.memory_stats.usage ?? 0;
45
+ const memLimit = stats.memory_stats.limit ?? 0;
46
+ const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100 : 0;
47
+
48
+ return {
49
+ content: [{
50
+ type: "text",
51
+ text: JSON.stringify({
52
+ name: params.container_id,
53
+ cpu: {
54
+ percent: parseFloat(cpuPercent.toFixed(2)),
55
+ cores: cpuCount,
56
+ },
57
+ memory: {
58
+ usage: formatBytes(memUsage),
59
+ limit: formatBytes(memLimit),
60
+ percent: parseFloat(memPercent.toFixed(2)),
61
+ },
62
+ network: stats.networks
63
+ ? Object.fromEntries(
64
+ Object.entries(stats.networks).map(([iface, data]) => [
65
+ iface,
66
+ {
67
+ rx: formatBytes((data as { rx_bytes?: number }).rx_bytes ?? 0),
68
+ tx: formatBytes((data as { tx_bytes?: number }).tx_bytes ?? 0),
69
+ },
70
+ ])
71
+ )
72
+ : {},
73
+ blockIO: {
74
+ read: formatBytes((stats.blkio_stats as unknown as { io_service_bytes?: Array<{ value?: number }> })?.io_service_bytes?.[0]?.value ?? 0),
75
+ write: formatBytes((stats.blkio_stats as unknown as { io_service_bytes?: Array<{ value?: number }> })?.io_service_bytes?.[1]?.value ?? 0),
76
+ },
77
+ }, null, 2),
78
+ }],
79
+ };
80
+ } catch (error) {
81
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
82
+ }
83
+ }
84
+ );
85
+ }
@@ -0,0 +1,60 @@
1
+ import Dockerode from "dockerode";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { ListNetworksSchema, ListVolumesSchema } from "../types.js";
4
+ import { formatError } from "../docker.js";
5
+
6
+ export function registerNetworkTools(server: McpServer, docker: Dockerode): void {
7
+ server.tool(
8
+ "list_networks",
9
+ "List Docker networks with optional filter. Returns network IDs, names, drivers, and scopes.",
10
+ ListNetworksSchema.shape,
11
+ async (params) => {
12
+ try {
13
+ const networks = await docker.listNetworks({
14
+ filters: params.filter ? JSON.stringify({ name: [params.filter] }) : undefined,
15
+ });
16
+ const results = networks.map((n) => ({
17
+ id: n.Id.substring(0, 12),
18
+ name: n.Name,
19
+ driver: n.Driver,
20
+ scope: n.Scope,
21
+ created: n.Created,
22
+ containers: n.Containers
23
+ ? Object.fromEntries(
24
+ Object.entries(n.Containers).map(([id, c]) => [
25
+ id.substring(0, 12),
26
+ { name: (c as { Name?: string }).Name, ipv4: (c as { IPv4Address?: string }).IPv4Address },
27
+ ])
28
+ )
29
+ : {},
30
+ }));
31
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
32
+ } catch (error) {
33
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
34
+ }
35
+ }
36
+ );
37
+
38
+ server.tool(
39
+ "list_volumes",
40
+ "List Docker volumes with optional filter. Returns volume names, drivers, mount points, and labels.",
41
+ ListVolumesSchema.shape,
42
+ async (params) => {
43
+ try {
44
+ const result = await docker.listVolumes({
45
+ filters: params.filter ? JSON.stringify({ name: [params.filter] }) : undefined,
46
+ });
47
+ const volumes = (result.Volumes || []).map((v) => ({
48
+ name: (v as unknown as Record<string, unknown>).Name,
49
+ driver: (v as unknown as Record<string, unknown>).Driver,
50
+ mountpoint: (v as unknown as Record<string, unknown>).Mountpoint,
51
+ created: (v as unknown as Record<string, unknown>).CreatedAt ?? (v as unknown as Record<string, unknown>).Created,
52
+ labels: (v as unknown as Record<string, unknown>).Labels,
53
+ }));
54
+ return { content: [{ type: "text", text: JSON.stringify(volumes, null, 2) }] };
55
+ } catch (error) {
56
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
57
+ }
58
+ }
59
+ );
60
+ }
package/src/types.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { z } from "zod";
2
+
3
+ // Container lifecycle schemas
4
+ export const ListContainersSchema = z.object({
5
+ all: z.boolean().optional().describe("Include stopped containers (default: false)"),
6
+ label: z.array(z.string()).optional().describe("Filter by label (e.g., 'app=web')"),
7
+ name: z.string().optional().describe("Filter by name (partial match)"),
8
+ state: z.enum(["running", "stopped", "paused", "exited", "created", "restarting"]).optional().describe("Filter by state"),
9
+ });
10
+
11
+ export const InspectContainerSchema = z.object({
12
+ container_id: z.string().describe("Container ID or name"),
13
+ });
14
+
15
+ export const StartContainerSchema = z.object({
16
+ container_id: z.string().describe("Container ID or name"),
17
+ });
18
+
19
+ export const StopContainerSchema = z.object({
20
+ container_id: z.string().describe("Container ID or name"),
21
+ timeout: z.number().optional().describe("Seconds to wait before killing (default: 10)"),
22
+ });
23
+
24
+ export const RestartContainerSchema = z.object({
25
+ container_id: z.string().describe("Container ID or name"),
26
+ timeout: z.number().optional().describe("Seconds to wait before killing (default: 10)"),
27
+ });
28
+
29
+ export const RemoveContainerSchema = z.object({
30
+ container_id: z.string().describe("Container ID or name"),
31
+ force: z.boolean().optional().describe("Force removal even if running (default: false)"),
32
+ });
33
+
34
+ export const RecreateContainerSchema = z.object({
35
+ container_id: z.string().describe("Container ID or name"),
36
+ timeout: z.number().optional().describe("Seconds to wait before killing (default: 10)"),
37
+ });
38
+
39
+ export const RunContainerSchema = z.object({
40
+ image: z.string().describe("Image name (e.g., 'nginx:latest')"),
41
+ name: z.string().optional().describe("Container name"),
42
+ env: z.record(z.string()).optional().describe("Environment variables"),
43
+ ports: z.record(z.string()).optional().describe("Port mappings (e.g., {'8080/tcp': '80/tcp'})"),
44
+ volumes: z.array(z.string()).optional().describe("Volume mounts (e.g., ['/host/path:/container/path'])"),
45
+ restart_policy: z.enum(["no", "always", "unless-stopped", "on-failure"]).optional().describe("Restart policy"),
46
+ command: z.array(z.string()).optional().describe("Override command"),
47
+ detach: z.boolean().optional().describe("Run in detached mode (default: true)"),
48
+ });
49
+
50
+ // Image management schemas
51
+ export const ListImagesSchema = z.object({
52
+ all: z.boolean().optional().describe("Include intermediate images (default: false)"),
53
+ filter: z.string().optional().describe("Filter by reference"),
54
+ });
55
+
56
+ export const PullImageSchema = z.object({
57
+ image: z.string().describe("Image to pull (e.g., 'nginx:latest')"),
58
+ tag: z.string().optional().describe("Tag to pull (default: 'latest')"),
59
+ });
60
+
61
+ export const BuildImageSchema = z.object({
62
+ context: z.string().describe("Build context path or Dockerfile content"),
63
+ tag: z.string().describe("Tag for the built image (e.g., 'myapp:v1')"),
64
+ dockerfile: z.string().optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
65
+ build_args: z.record(z.string()).optional().describe("Build arguments"),
66
+ target: z.string().optional().describe("Target build stage"),
67
+ });
68
+
69
+ export const RemoveImageSchema = z.object({
70
+ image: z.string().describe("Image name or ID"),
71
+ force: z.boolean().optional().describe("Force removal (default: false)"),
72
+ });
73
+
74
+ // Docker Compose schemas
75
+ export const ComposeUpSchema = z.object({
76
+ path: z.string().describe("Path to docker-compose.yml directory"),
77
+ build: z.boolean().optional().describe("Build images before starting (default: false)"),
78
+ detach: z.boolean().optional().describe("Run in detached mode (default: true)"),
79
+ services: z.array(z.string()).optional().describe("Specific services to start"),
80
+ });
81
+
82
+ export const ComposeDownSchema = z.object({
83
+ path: z.string().describe("Path to docker-compose.yml directory"),
84
+ volumes: z.boolean().optional().describe("Remove named volumes (default: false)"),
85
+ timeout: z.number().optional().describe("Shutdown timeout in seconds (default: 10)"),
86
+ });
87
+
88
+ export const ComposePsSchema = z.object({
89
+ path: z.string().describe("Path to docker-compose.yml directory"),
90
+ });
91
+
92
+ export const ComposeLogsSchema = z.object({
93
+ path: z.string().describe("Path to docker-compose.yml directory"),
94
+ services: z.array(z.string()).optional().describe("Specific services to tail"),
95
+ tail: z.number().optional().describe("Number of lines to show (default: 100)"),
96
+ follow: z.boolean().optional().describe("Follow log output (default: false)"),
97
+ });
98
+
99
+ export const ComposeRestartSchema = z.object({
100
+ path: z.string().describe("Path to docker-compose.yml directory"),
101
+ services: z.array(z.string()).optional().describe("Specific services to restart (empty = all)"),
102
+ timeout: z.number().optional().describe("Shutdown timeout in seconds (default: 10)"),
103
+ });
104
+
105
+ // Health schemas
106
+ export const CheckHealthSchema = z.object({
107
+ container_id: z.string().describe("Container ID or name"),
108
+ type: z.enum(["http", "tcp", "exec"]).optional().describe("Probe type (default: auto-detect from HEALTHCHECK)"),
109
+ endpoint: z.string().optional().describe("HTTP endpoint or TCP port"),
110
+ command: z.array(z.string()).optional().describe("Command for exec probe"),
111
+ });
112
+
113
+ export const WatchHealthSchema = z.object({
114
+ container_id: z.string().describe("Container ID or name"),
115
+ timeout: z.number().optional().describe("Max seconds to wait (default: 60)"),
116
+ interval: z.number().optional().describe("Seconds between polls (default: 5)"),
117
+ });
118
+
119
+ export const SetRestartPolicySchema = z.object({
120
+ container_id: z.string().describe("Container ID or name"),
121
+ policy: z.enum(["no", "always", "unless-stopped", "on-failure"]).describe("Restart policy"),
122
+ max_retry_count: z.number().optional().describe("Max retry count for on-failure (default: 0)"),
123
+ });
124
+
125
+ // Logs schemas
126
+ export const StreamLogsSchema = z.object({
127
+ container_id: z.string().describe("Container ID or name"),
128
+ tail: z.number().optional().describe("Number of lines to show (default: 100)"),
129
+ since: z.string().optional().describe("Show logs since timestamp (e.g., '2026-01-01T00:00:00Z')"),
130
+ follow: z.boolean().optional().describe("Follow log output (default: false)"),
131
+ });
132
+
133
+ export const ContainerStatsSchema = z.object({
134
+ container_id: z.string().describe("Container ID or name"),
135
+ });
136
+
137
+ // Exec schema
138
+ export const ExecInContainerSchema = z.object({
139
+ container_id: z.string().describe("Container ID or name"),
140
+ command: z.array(z.string()).describe("Command to execute"),
141
+ working_dir: z.string().optional().describe("Working directory inside container"),
142
+ env: z.record(z.string()).optional().describe("Environment variables"),
143
+ });
144
+
145
+ // Network/Volume schemas
146
+ export const ListNetworksSchema = z.object({
147
+ filter: z.string().optional().describe("Filter by name or driver"),
148
+ });
149
+
150
+ export const ListVolumesSchema = z.object({
151
+ filter: z.string().optional().describe("Filter by name or driver"),
152
+ });