@supernova123/docker-mcp-server 0.3.0 → 0.3.2
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 +39 -0
- package/dist/docker.js +149 -0
- package/dist/index.js +10 -2
- package/dist/server.d.ts +4 -1
- package/dist/server.js +2 -2
- package/dist/tools/container.js +6 -6
- package/dist/tools/image.js +3 -3
- package/dist/tools/logs.js +3 -3
- package/dist/tools/network.js +3 -3
- package/dist/tools/volume.js +3 -3
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
- package/src/docker.ts +165 -1
- package/src/index.ts +13 -2
- package/src/server.ts +6 -2
- package/src/tools/container.ts +14 -11
- package/src/tools/health.ts +1 -1
- package/src/tools/image.ts +3 -3
- package/src/tools/logs.ts +3 -3
- package/src/tools/network.ts +3 -3
- package/src/tools/volume.ts +4 -4
- package/tests/retry.test.ts +82 -0
- package/src/docker.ts.bak +0 -85
- package/src/tools/monitoring.ts.bak +0 -376
package/src/tools/container.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
RecreateContainerSchema,
|
|
11
11
|
RunContainerSchema,
|
|
12
12
|
} from "../types.js";
|
|
13
|
-
import { formatContainer, formatError } from "../docker.js";
|
|
13
|
+
import { formatContainer, formatError, withRetry } from "../docker.js";
|
|
14
14
|
|
|
15
15
|
export function registerContainerTools(server: McpServer, docker: Dockerode): void {
|
|
16
16
|
server.tool(
|
|
@@ -20,14 +20,17 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
20
20
|
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
21
21
|
async (params) => {
|
|
22
22
|
try {
|
|
23
|
-
const containers = await
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const containers = await withRetry(
|
|
24
|
+
() => docker.listContainers({
|
|
25
|
+
all: params.all ?? false,
|
|
26
|
+
filters: JSON.stringify({
|
|
27
|
+
...(params.label ? { label: params.label } : {}),
|
|
28
|
+
...(params.name ? { name: [`/${params.name}`] } : {}),
|
|
29
|
+
...(params.state ? { status: [params.state] } : {}),
|
|
30
|
+
}),
|
|
29
31
|
}),
|
|
30
|
-
|
|
32
|
+
{ label: "list_containers" }
|
|
33
|
+
);
|
|
31
34
|
const results = containers.map(formatContainer);
|
|
32
35
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
33
36
|
} catch (error) {
|
|
@@ -60,7 +63,7 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
60
63
|
async (params) => {
|
|
61
64
|
try {
|
|
62
65
|
const container = docker.getContainer(params.container_id);
|
|
63
|
-
await container.start();
|
|
66
|
+
await withRetry(() => container.start(), { label: "start_container" });
|
|
64
67
|
return { content: [{ type: "text", text: `Container ${params.container_id} started.` }] };
|
|
65
68
|
} catch (error) {
|
|
66
69
|
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
|
@@ -76,7 +79,7 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
76
79
|
async (params) => {
|
|
77
80
|
try {
|
|
78
81
|
const container = docker.getContainer(params.container_id);
|
|
79
|
-
await container.stop({ t: params.timeout ?? 10 });
|
|
82
|
+
await withRetry(() => container.stop({ t: params.timeout ?? 10 }), { label: "stop_container" });
|
|
80
83
|
return { content: [{ type: "text", text: `Container ${params.container_id} stopped.` }] };
|
|
81
84
|
} catch (error: any) {
|
|
82
85
|
// 304 means container is already stopped — treat as success
|
|
@@ -96,7 +99,7 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
|
|
|
96
99
|
async (params) => {
|
|
97
100
|
try {
|
|
98
101
|
const container = docker.getContainer(params.container_id);
|
|
99
|
-
await container.restart({ t: params.timeout ?? 10 });
|
|
102
|
+
await withRetry(() => container.restart({ t: params.timeout ?? 10 }), { label: "restart_container" });
|
|
100
103
|
return { content: [{ type: "text", text: `Container ${params.container_id} restarted.` }] };
|
|
101
104
|
} catch (error) {
|
|
102
105
|
return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
|
package/src/tools/health.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Dockerode from "dockerode";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { CheckHealthSchema, WatchHealthSchema, SetRestartPolicySchema } from "../types.js";
|
|
4
|
-
import { formatError } from "../docker.js";
|
|
4
|
+
import { formatError, withRetry } from "../docker.js";
|
|
5
5
|
|
|
6
6
|
export function registerHealthTools(server: McpServer, docker: Dockerode): void {
|
|
7
7
|
server.tool(
|
package/src/tools/image.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
BuildImageSchema,
|
|
7
7
|
RemoveImageSchema,
|
|
8
8
|
} from "../types.js";
|
|
9
|
-
import { formatImage, formatError } from "../docker.js";
|
|
9
|
+
import { formatImage, formatError, withRetry } from "../docker.js";
|
|
10
10
|
|
|
11
11
|
export function registerImageTools(server: McpServer, docker: Dockerode): void {
|
|
12
12
|
server.tool(
|
|
@@ -16,10 +16,10 @@ export function registerImageTools(server: McpServer, docker: Dockerode): void {
|
|
|
16
16
|
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
17
17
|
async (params) => {
|
|
18
18
|
try {
|
|
19
|
-
const images = await docker.listImages({
|
|
19
|
+
const images = await withRetry(() => docker.listImages({
|
|
20
20
|
all: params.all ?? false,
|
|
21
21
|
filters: params.filter ? JSON.stringify({ reference: [params.filter] }) : undefined,
|
|
22
|
-
});
|
|
22
|
+
}), { label: "list_images" });
|
|
23
23
|
const results = images.map(formatImage);
|
|
24
24
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
25
25
|
} catch (error) {
|
package/src/tools/logs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Dockerode from "dockerode";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
|
|
4
|
-
import { formatError,
|
|
4
|
+
import { formatError, sanitizeOutput, withRetry, formatBytes } from "../docker.js";
|
|
5
5
|
|
|
6
6
|
export function registerLogsTools(server: McpServer, docker: Dockerode): void {
|
|
7
7
|
server.tool(
|
|
@@ -12,13 +12,13 @@ export function registerLogsTools(server: McpServer, docker: Dockerode): void {
|
|
|
12
12
|
async (params) => {
|
|
13
13
|
try {
|
|
14
14
|
const container = docker.getContainer(params.container_id);
|
|
15
|
-
const logs = await container.logs({
|
|
15
|
+
const logs = await withRetry(() => container.logs({
|
|
16
16
|
stdout: true,
|
|
17
17
|
stderr: true,
|
|
18
18
|
tail: params.tail ?? 100,
|
|
19
19
|
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
20
20
|
follow: false as const,
|
|
21
|
-
});
|
|
21
|
+
}), { label: "container_logs" });
|
|
22
22
|
// Dockerode returns a Buffer with multiplexed stream headers
|
|
23
23
|
// Use 100KB cap for logs to keep LLM context small
|
|
24
24
|
const output = sanitizeOutput(logs.toString("utf-8"), 100_000);
|
package/src/tools/network.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Dockerode from "dockerode";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { ListNetworksSchema, ListVolumesSchema } from "../types.js";
|
|
4
|
-
import { formatError } from "../docker.js";
|
|
4
|
+
import { formatError, withRetry } from "../docker.js";
|
|
5
5
|
|
|
6
6
|
export function registerNetworkTools(server: McpServer, docker: Dockerode): void {
|
|
7
7
|
server.tool(
|
|
@@ -11,9 +11,9 @@ export function registerNetworkTools(server: McpServer, docker: Dockerode): void
|
|
|
11
11
|
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
12
12
|
async (params) => {
|
|
13
13
|
try {
|
|
14
|
-
const networks = await docker.listNetworks({
|
|
14
|
+
const networks = await withRetry(() => docker.listNetworks({
|
|
15
15
|
filters: params.filter ? JSON.stringify({ name: [params.filter] }) : undefined,
|
|
16
|
-
});
|
|
16
|
+
}), { label: "list_networks" });
|
|
17
17
|
const results = networks.map((n) => ({
|
|
18
18
|
id: n.Id.substring(0, 12),
|
|
19
19
|
name: n.Name,
|
package/src/tools/volume.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
RemoveVolumeSchema,
|
|
7
7
|
PruneVolumesSchema,
|
|
8
8
|
} from "../types.js";
|
|
9
|
-
import { formatError } from "../docker.js";
|
|
9
|
+
import { formatError, withRetry } from "../docker.js";
|
|
10
10
|
|
|
11
11
|
export function registerVolumeTools(server: McpServer, docker: Dockerode): void {
|
|
12
12
|
server.tool(
|
|
@@ -16,12 +16,12 @@ export function registerVolumeTools(server: McpServer, docker: Dockerode): void
|
|
|
16
16
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
17
17
|
async (params) => {
|
|
18
18
|
try {
|
|
19
|
-
const result = await docker.createVolume({
|
|
19
|
+
const result = await withRetry(() => docker.createVolume({
|
|
20
20
|
Name: params.name,
|
|
21
21
|
Driver: params.driver || "local",
|
|
22
22
|
Labels: params.labels,
|
|
23
23
|
DriverOpts: params.options,
|
|
24
|
-
});
|
|
24
|
+
}), { label: "create_volume" });
|
|
25
25
|
return {
|
|
26
26
|
content: [{
|
|
27
27
|
type: "text",
|
|
@@ -122,4 +122,4 @@ export function registerVolumeTools(server: McpServer, docker: Dockerode): void
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
);
|
|
125
|
-
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { withRetry, DockerTimeoutError, DockerConnectionError, DockerPermissionError } from "../src/docker.js";
|
|
3
|
+
|
|
4
|
+
describe("withRetry", () => {
|
|
5
|
+
it("returns result on first attempt (no retry needed)", async () => {
|
|
6
|
+
const fn = vi.fn().mockResolvedValue("success");
|
|
7
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
8
|
+
expect(result).toBe("success");
|
|
9
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("retries on transient ECONNRESET error", async () => {
|
|
13
|
+
const fn = vi.fn()
|
|
14
|
+
.mockRejectedValueOnce(new Error("read ECONNRESET"))
|
|
15
|
+
.mockResolvedValue("recovered");
|
|
16
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10, label: "test" });
|
|
17
|
+
expect(result).toBe("recovered");
|
|
18
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("retries on timeout error", async () => {
|
|
22
|
+
const fn = vi.fn()
|
|
23
|
+
.mockRejectedValueOnce(new DockerTimeoutError("timed out"))
|
|
24
|
+
.mockResolvedValue("recovered");
|
|
25
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
26
|
+
expect(result).toBe("recovered");
|
|
27
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does NOT retry on 404 Not Found", async () => {
|
|
31
|
+
const fn = vi.fn().mockRejectedValue(new Error("HTTP 404: Not Found"));
|
|
32
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
33
|
+
.rejects.toThrow("HTTP 404: Not Found");
|
|
34
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does NOT retry on permission denied", async () => {
|
|
38
|
+
const fn = vi.fn().mockRejectedValue(new Error("Permission denied"));
|
|
39
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
40
|
+
.rejects.toThrow("Permission denied");
|
|
41
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does NOT retry on DockerConnectionError", async () => {
|
|
45
|
+
const fn = vi.fn().mockRejectedValue(new DockerConnectionError("cannot connect"));
|
|
46
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
47
|
+
.rejects.toThrow("cannot connect");
|
|
48
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does NOT retry on DockerPermissionError", async () => {
|
|
52
|
+
const fn = vi.fn().mockRejectedValue(new DockerPermissionError("access denied"));
|
|
53
|
+
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 10 }))
|
|
54
|
+
.rejects.toThrow("access denied");
|
|
55
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("retries on 500 Internal Server Error", async () => {
|
|
59
|
+
const fn = vi.fn()
|
|
60
|
+
.mockRejectedValueOnce(new Error("HTTP 500: Internal Server Error"))
|
|
61
|
+
.mockResolvedValue("recovered");
|
|
62
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
63
|
+
expect(result).toBe("recovered");
|
|
64
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("exhausts retries and throws last error", async () => {
|
|
68
|
+
const fn = vi.fn().mockRejectedValue(new Error("read ECONNRESET"));
|
|
69
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 10 }))
|
|
70
|
+
.rejects.toThrow("read ECONNRESET");
|
|
71
|
+
expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("retries on socket hang up", async () => {
|
|
75
|
+
const fn = vi.fn()
|
|
76
|
+
.mockRejectedValueOnce(new Error("socket hang up"))
|
|
77
|
+
.mockResolvedValue("ok");
|
|
78
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
|
79
|
+
expect(result).toBe("ok");
|
|
80
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/docker.ts.bak
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
}
|