@supernova123/docker-mcp-server 0.1.6 → 0.2.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/README.md +21 -0
- package/dist/server.js +3 -1
- package/dist/tools/monitoring.d.ts +4 -0
- package/dist/tools/monitoring.js +309 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +24 -0
- package/package.json +16 -4
- package/src/server.ts +3 -1
- package/src/tools/monitoring.ts +369 -0
- package/src/types.ts +31 -0
- package/tests/monitoring.test.ts +411 -0
package/README.md
CHANGED
|
@@ -24,8 +24,19 @@ There are 11+ Docker MCP servers on npm. Most are stale, GPL-licensed, or only c
|
|
|
24
24
|
| **Auto-restart** | ✅ set_restart_policy | ❌ | ❌ |
|
|
25
25
|
| **Compose lifecycle** | ✅ up/down/ps/logs/restart | ❌ | ❌ |
|
|
26
26
|
| **Log streaming** | ✅ tail + timestamp filter | Basic | Basic |
|
|
27
|
+
| **Fleet monitoring** | ✅ 6 fleet tools (status, stats, events, logs, thresholds, dashboard) | ❌ | ❌ |
|
|
27
28
|
| **Agent positioning** | ✅ Built for agents | Generic Docker | Registry API |
|
|
28
29
|
|
|
30
|
+
## Use Cases
|
|
31
|
+
|
|
32
|
+
**Agent-managed deployments:** Your agent deploys a new version, checks health, waits for readiness, then switches traffic. If the health check fails, it auto-rolls back.
|
|
33
|
+
|
|
34
|
+
**Self-healing infrastructure:** Set `restart: always` on critical containers. Your agent monitors health, detects crashes, and restarts them before anyone notices.
|
|
35
|
+
|
|
36
|
+
**Compose stack orchestration:** Your agent brings up a full stack (app + db + redis), monitors service states, tails logs for errors, and tears down cleanly when done.
|
|
37
|
+
|
|
38
|
+
**Debugging sessions:** Your agent execs into a container, runs diagnostics, streams logs with timestamp filters, and captures stats — all without SSH.
|
|
39
|
+
|
|
29
40
|
## Quick Start
|
|
30
41
|
|
|
31
42
|
One command to run:
|
|
@@ -86,6 +97,16 @@ claude mcp add docker -- npx -y @supernova123/docker-mcp-server
|
|
|
86
97
|
| `compose_logs` | Tail Compose service logs |
|
|
87
98
|
| `compose_restart` | Restart Compose services |
|
|
88
99
|
|
|
100
|
+
### Fleet Monitoring
|
|
101
|
+
| Tool | Description |
|
|
102
|
+
|------|-------------|
|
|
103
|
+
| `fleet_status` | Health status of all running containers (state, health, uptime, restart count) |
|
|
104
|
+
| `fleet_stats` | Resource usage (CPU%, memory%, network I/O) for all running containers |
|
|
105
|
+
| `watch_events` | Collect Docker events (start, stop, die, restart, health) over a time window |
|
|
106
|
+
| `search_logs` | Search logs across multiple containers with regex/grep pattern |
|
|
107
|
+
| `check_thresholds` | Check containers against CPU/memory/restart thresholds, return violations |
|
|
108
|
+
| `monitor_dashboard` | Single-call fleet summary: health, top consumers, recent events, violations |
|
|
109
|
+
|
|
89
110
|
### Health & Self-Healing
|
|
90
111
|
| Tool | Description |
|
|
91
112
|
|------|-------------|
|
package/dist/server.js
CHANGED
|
@@ -6,10 +6,11 @@ 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 { registerMonitoringTools } from "./tools/monitoring.js";
|
|
9
10
|
export function createServer(docker) {
|
|
10
11
|
const server = new McpServer({
|
|
11
12
|
name: "docker-mcp-server",
|
|
12
|
-
version: "0.
|
|
13
|
+
version: "0.2.0",
|
|
13
14
|
});
|
|
14
15
|
// Register all tool categories
|
|
15
16
|
registerContainerTools(server, docker);
|
|
@@ -19,6 +20,7 @@ export function createServer(docker) {
|
|
|
19
20
|
registerLogsTools(server, docker);
|
|
20
21
|
registerExecTools(server, docker);
|
|
21
22
|
registerNetworkTools(server, docker);
|
|
23
|
+
registerMonitoringTools(server, docker);
|
|
22
24
|
return server;
|
|
23
25
|
}
|
|
24
26
|
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { ContainerHealthStatusSchema, ContainerResourceUsageSchema, WatchEventsSchema, SearchLogsSchema, ResourceAlertCheckSchema, MonitorDashboardSchema, } from "../types.js";
|
|
2
|
+
export function registerMonitoringTools(server, docker) {
|
|
3
|
+
// 1. fleet_status — health status of all running containers
|
|
4
|
+
server.tool("container_health_status", "Check health status, uptime, and restart count for all running Docker containers. Returns JSON with container name, state, health probe status, and restart count.", ContainerHealthStatusSchema.shape, async (params) => {
|
|
5
|
+
try {
|
|
6
|
+
const containers = await docker.listContainers({ all: false });
|
|
7
|
+
const results = await Promise.all(containers.map(async (c) => {
|
|
8
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
9
|
+
return {
|
|
10
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
11
|
+
id: c.Id.slice(0, 12),
|
|
12
|
+
state: c.State,
|
|
13
|
+
status: c.Status,
|
|
14
|
+
health: info.State.Health?.Status || "no-healthcheck",
|
|
15
|
+
uptime: info.State.StartedAt,
|
|
16
|
+
restartCount: info.RestartCount,
|
|
17
|
+
image: c.Image,
|
|
18
|
+
};
|
|
19
|
+
}));
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
27
|
+
isError: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// 2. fleet_stats — resource usage for all running containers
|
|
32
|
+
server.tool("container_resource_usage", "Monitor CPU, memory, and network I/O across all running Docker containers. Returns sorted resource usage metrics with percentage breakdowns.", ContainerResourceUsageSchema.shape, async (params) => {
|
|
33
|
+
try {
|
|
34
|
+
const containers = await docker.listContainers({ all: false });
|
|
35
|
+
const results = await Promise.all(containers.map(async (c) => {
|
|
36
|
+
const stats = await docker.getContainer(c.Id).stats({ stream: false });
|
|
37
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
38
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
39
|
+
const cpuCount = stats.cpu_stats.online_cpus ?? 1;
|
|
40
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
41
|
+
const memUsage = stats.memory_stats?.usage ?? 0;
|
|
42
|
+
const memLimit = stats.memory_stats?.limit ?? 1;
|
|
43
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
44
|
+
const netRx = Object.values(stats.networks ?? {}).reduce((sum, n) => sum + (n.rx_bytes ?? 0), 0);
|
|
45
|
+
const netTx = Object.values(stats.networks ?? {}).reduce((sum, n) => sum + (n.tx_bytes ?? 0), 0);
|
|
46
|
+
return {
|
|
47
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
48
|
+
id: c.Id.slice(0, 12),
|
|
49
|
+
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
|
50
|
+
memory_usage_mb: Math.round((memUsage / 1024 / 1024) * 100) / 100,
|
|
51
|
+
memory_percent: Math.round(memPercent * 100) / 100,
|
|
52
|
+
network_rx_mb: Math.round((netRx / 1024 / 1024) * 100) / 100,
|
|
53
|
+
network_tx_mb: Math.round((netTx / 1024 / 1024) * 100) / 100,
|
|
54
|
+
};
|
|
55
|
+
}));
|
|
56
|
+
const sortBy = params.sort_by || "cpu";
|
|
57
|
+
results.sort((a, b) => {
|
|
58
|
+
if (sortBy === "cpu")
|
|
59
|
+
return b.cpu_percent - a.cpu_percent;
|
|
60
|
+
if (sortBy === "memory")
|
|
61
|
+
return b.memory_percent - a.memory_percent;
|
|
62
|
+
return (b.network_rx_mb + b.network_tx_mb) - (a.network_rx_mb + a.network_tx_mb);
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
// 3. watch_events — stream Docker events (simplified: collect events for a duration)
|
|
76
|
+
server.tool("watch_events", "Stream Docker container events (start, stop, die, restart, health_status) over a configurable time window. Filter by specific container or event type.", WatchEventsSchema.shape, async (params) => {
|
|
77
|
+
try {
|
|
78
|
+
const durationMs = (params.duration || 30) * 1000;
|
|
79
|
+
const filter = {};
|
|
80
|
+
if (params.container)
|
|
81
|
+
filter.container = [params.container];
|
|
82
|
+
if (params.event_type && params.event_type !== "all")
|
|
83
|
+
filter.event = [params.event_type];
|
|
84
|
+
if (params.since)
|
|
85
|
+
filter.since = [params.since];
|
|
86
|
+
const events = [];
|
|
87
|
+
const stream = await docker.getEvents(filter);
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
resolve();
|
|
91
|
+
}, durationMs);
|
|
92
|
+
stream.on("data", (chunk) => {
|
|
93
|
+
try {
|
|
94
|
+
const event = JSON.parse(chunk.toString());
|
|
95
|
+
events.push({
|
|
96
|
+
type: event.Type,
|
|
97
|
+
action: event.Action,
|
|
98
|
+
container: event.Actor?.Attributes?.name || event.Actor?.ID?.slice(0, 12),
|
|
99
|
+
time: new Date(event.time * 1000).toISOString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
});
|
|
104
|
+
stream.on("error", () => {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
resolve();
|
|
107
|
+
});
|
|
108
|
+
stream.on("end", () => {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
resolve();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: events.length ? JSON.stringify(events, null, 2) : "No events captured in the time window." }],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
// 4. search_logs — search logs across multiple containers
|
|
125
|
+
server.tool("search_logs", "Search Docker container logs across multiple containers using regex pattern matching. Returns matching log lines with container name and timestamp.", SearchLogsSchema.shape, async (params) => {
|
|
126
|
+
try {
|
|
127
|
+
const targetContainers = params.containers || [];
|
|
128
|
+
let containers;
|
|
129
|
+
if (targetContainers.length > 0) {
|
|
130
|
+
containers = await Promise.all(targetContainers.map(async (id) => {
|
|
131
|
+
const info = await docker.getContainer(id).inspect();
|
|
132
|
+
return { id, name: info.Name.replace(/^\//, "") };
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const list = await docker.listContainers({ all: false });
|
|
137
|
+
containers = list.map((c) => ({ id: c.Id, name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12) }));
|
|
138
|
+
}
|
|
139
|
+
const regex = new RegExp(params.pattern, params.ignore_case ? "i" : "");
|
|
140
|
+
const matches = [];
|
|
141
|
+
for (const container of containers) {
|
|
142
|
+
try {
|
|
143
|
+
const logStream = await docker.getContainer(container.id).logs({
|
|
144
|
+
stdout: true,
|
|
145
|
+
stderr: true,
|
|
146
|
+
tail: params.tail || 500,
|
|
147
|
+
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
148
|
+
});
|
|
149
|
+
const output = logStream.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
|
|
150
|
+
const lines = output.split("\n");
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
if (regex.test(line)) {
|
|
153
|
+
matches.push({ container: container.name, line: line.trim() });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch { }
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: matches.length ? JSON.stringify(matches, null, 2) : "No matches found." }],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
166
|
+
isError: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// 5. check_thresholds — check all containers against thresholds
|
|
171
|
+
server.tool("resource_alert_check", "Alert when Docker containers exceed resource thresholds (CPU%, memory%, restart count). Returns violations with specific metrics that triggered alerts.", ResourceAlertCheckSchema.shape, async (params) => {
|
|
172
|
+
try {
|
|
173
|
+
const cpuThreshold = params.cpu_percent ?? 80;
|
|
174
|
+
const memThreshold = params.memory_percent ?? 80;
|
|
175
|
+
const restartThreshold = params.restart_count ?? 5;
|
|
176
|
+
const containers = await docker.listContainers({ all: false });
|
|
177
|
+
const violations = [];
|
|
178
|
+
for (const c of containers) {
|
|
179
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
180
|
+
const issues = [];
|
|
181
|
+
// Check restart count
|
|
182
|
+
if (info.RestartCount > restartThreshold) {
|
|
183
|
+
issues.push(`restarts: ${info.RestartCount} > ${restartThreshold}`);
|
|
184
|
+
}
|
|
185
|
+
// Check CPU and memory
|
|
186
|
+
try {
|
|
187
|
+
const stats = await docker.getContainer(c.Id).stats({ stream: false });
|
|
188
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
189
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
190
|
+
const cpuCount = stats.cpu_stats.online_cpus ?? 1;
|
|
191
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
192
|
+
const memUsage = stats.memory_stats?.usage ?? 0;
|
|
193
|
+
const memLimit = stats.memory_stats?.limit ?? 1;
|
|
194
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
195
|
+
if (cpuPercent > cpuThreshold)
|
|
196
|
+
issues.push(`cpu: ${Math.round(cpuPercent)}% > ${cpuThreshold}%`);
|
|
197
|
+
if (memPercent > memThreshold)
|
|
198
|
+
issues.push(`memory: ${Math.round(memPercent)}% > ${memThreshold}%`);
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
if (issues.length > 0) {
|
|
202
|
+
violations.push({
|
|
203
|
+
container: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
204
|
+
id: c.Id.slice(0, 12),
|
|
205
|
+
issues,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
content: [{
|
|
211
|
+
type: "text",
|
|
212
|
+
text: violations.length
|
|
213
|
+
? JSON.stringify({ violations, checked: containers.length }, null, 2)
|
|
214
|
+
: JSON.stringify({ message: "All containers within thresholds.", checked: containers.length }),
|
|
215
|
+
}],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
221
|
+
isError: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// 6. monitor_dashboard — single-call fleet summary
|
|
226
|
+
server.tool("monitor_dashboard", "Comprehensive Docker fleet dashboard in a single API call. Returns health status, top resource consumers, recent events, and threshold violations.", MonitorDashboardSchema.shape, async (params) => {
|
|
227
|
+
try {
|
|
228
|
+
const containers = await docker.listContainers({ all: false });
|
|
229
|
+
// Fleet health
|
|
230
|
+
const health = await Promise.all(containers.map(async (c) => {
|
|
231
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
232
|
+
return {
|
|
233
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
234
|
+
state: c.State,
|
|
235
|
+
health: info.State.Health?.Status || "no-healthcheck",
|
|
236
|
+
restartCount: info.RestartCount,
|
|
237
|
+
};
|
|
238
|
+
}));
|
|
239
|
+
// Resource usage (top 5 by CPU)
|
|
240
|
+
const stats = await Promise.all(containers.map(async (c) => {
|
|
241
|
+
try {
|
|
242
|
+
const s = await docker.getContainer(c.Id).stats({ stream: false });
|
|
243
|
+
const cpuDelta = s.cpu_stats.cpu_usage.total_usage - (s.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
244
|
+
const systemDelta = s.cpu_stats.system_cpu_usage - (s.precpu_stats?.system_cpu_usage ?? 0);
|
|
245
|
+
const cpuCount = s.cpu_stats.online_cpus ?? 1;
|
|
246
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
247
|
+
const memUsage = s.memory_stats?.usage ?? 0;
|
|
248
|
+
const memLimit = s.memory_stats?.limit ?? 1;
|
|
249
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
250
|
+
return {
|
|
251
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
252
|
+
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
|
253
|
+
memory_percent: Math.round(memPercent * 100) / 100,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
const topConsumers = stats.filter(Boolean).sort((a, b) => b.cpu_percent - a.cpu_percent).slice(0, 5);
|
|
261
|
+
// Recent events (last 5 minutes) - use simple approach
|
|
262
|
+
const recentEvents = [];
|
|
263
|
+
try {
|
|
264
|
+
const sinceTs = Math.floor((Date.now() - 5 * 60 * 1000) / 1000);
|
|
265
|
+
const eventStream = await docker.getEvents({ since: sinceTs });
|
|
266
|
+
await new Promise((resolve) => {
|
|
267
|
+
const timeout = setTimeout(() => { resolve(); }, 2000);
|
|
268
|
+
eventStream.on("data", (chunk) => {
|
|
269
|
+
try {
|
|
270
|
+
const e = JSON.parse(chunk.toString());
|
|
271
|
+
recentEvents.push({
|
|
272
|
+
action: e.Action,
|
|
273
|
+
container: e.Actor?.Attributes?.name || e.Actor?.ID?.slice(0, 12),
|
|
274
|
+
time: new Date(e.time * 1000).toISOString(),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
catch { }
|
|
278
|
+
});
|
|
279
|
+
eventStream.on("error", () => { clearTimeout(timeout); resolve(); });
|
|
280
|
+
eventStream.on("end", () => { clearTimeout(timeout); resolve(); });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
catch { }
|
|
284
|
+
// Threshold violations
|
|
285
|
+
const violations = stats.filter(Boolean).filter((s) => s.cpu_percent > 80 || s.memory_percent > 80);
|
|
286
|
+
const dashboard = {
|
|
287
|
+
summary: {
|
|
288
|
+
total_containers: containers.length,
|
|
289
|
+
running: containers.filter((c) => c.State === "running").length,
|
|
290
|
+
unhealthy: health.filter((h) => h.health === "unhealthy").length,
|
|
291
|
+
},
|
|
292
|
+
health,
|
|
293
|
+
top_cpu_consumers: topConsumers,
|
|
294
|
+
recent_events: recentEvents.slice(0, 10),
|
|
295
|
+
threshold_violations: violations,
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: "text", text: JSON.stringify(dashboard, null, 2) }],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
304
|
+
isError: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=monitoring.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -306,4 +306,61 @@ export declare const ListVolumesSchema: z.ZodObject<{
|
|
|
306
306
|
}, {
|
|
307
307
|
filter?: string | undefined;
|
|
308
308
|
}>;
|
|
309
|
+
export declare const ContainerHealthStatusSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
310
|
+
export declare const ContainerResourceUsageSchema: z.ZodObject<{
|
|
311
|
+
sort_by: z.ZodOptional<z.ZodEnum<["cpu", "memory", "network"]>>;
|
|
312
|
+
}, "strip", z.ZodTypeAny, {
|
|
313
|
+
sort_by?: "cpu" | "memory" | "network" | undefined;
|
|
314
|
+
}, {
|
|
315
|
+
sort_by?: "cpu" | "memory" | "network" | undefined;
|
|
316
|
+
}>;
|
|
317
|
+
export declare const WatchEventsSchema: z.ZodObject<{
|
|
318
|
+
container: z.ZodOptional<z.ZodString>;
|
|
319
|
+
event_type: z.ZodOptional<z.ZodEnum<["start", "stop", "die", "restart", "health_status", "oom", "all"]>>;
|
|
320
|
+
since: z.ZodOptional<z.ZodString>;
|
|
321
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
322
|
+
}, "strip", z.ZodTypeAny, {
|
|
323
|
+
since?: string | undefined;
|
|
324
|
+
container?: string | undefined;
|
|
325
|
+
event_type?: "all" | "start" | "stop" | "die" | "restart" | "health_status" | "oom" | undefined;
|
|
326
|
+
duration?: number | undefined;
|
|
327
|
+
}, {
|
|
328
|
+
since?: string | undefined;
|
|
329
|
+
container?: string | undefined;
|
|
330
|
+
event_type?: "all" | "start" | "stop" | "die" | "restart" | "health_status" | "oom" | undefined;
|
|
331
|
+
duration?: number | undefined;
|
|
332
|
+
}>;
|
|
333
|
+
export declare const SearchLogsSchema: z.ZodObject<{
|
|
334
|
+
pattern: z.ZodString;
|
|
335
|
+
containers: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
336
|
+
tail: z.ZodOptional<z.ZodNumber>;
|
|
337
|
+
since: z.ZodOptional<z.ZodString>;
|
|
338
|
+
ignore_case: z.ZodOptional<z.ZodBoolean>;
|
|
339
|
+
}, "strip", z.ZodTypeAny, {
|
|
340
|
+
pattern: string;
|
|
341
|
+
tail?: number | undefined;
|
|
342
|
+
since?: string | undefined;
|
|
343
|
+
containers?: string[] | undefined;
|
|
344
|
+
ignore_case?: boolean | undefined;
|
|
345
|
+
}, {
|
|
346
|
+
pattern: string;
|
|
347
|
+
tail?: number | undefined;
|
|
348
|
+
since?: string | undefined;
|
|
349
|
+
containers?: string[] | undefined;
|
|
350
|
+
ignore_case?: boolean | undefined;
|
|
351
|
+
}>;
|
|
352
|
+
export declare const ResourceAlertCheckSchema: z.ZodObject<{
|
|
353
|
+
cpu_percent: z.ZodOptional<z.ZodNumber>;
|
|
354
|
+
memory_percent: z.ZodOptional<z.ZodNumber>;
|
|
355
|
+
restart_count: z.ZodOptional<z.ZodNumber>;
|
|
356
|
+
}, "strip", z.ZodTypeAny, {
|
|
357
|
+
cpu_percent?: number | undefined;
|
|
358
|
+
memory_percent?: number | undefined;
|
|
359
|
+
restart_count?: number | undefined;
|
|
360
|
+
}, {
|
|
361
|
+
cpu_percent?: number | undefined;
|
|
362
|
+
memory_percent?: number | undefined;
|
|
363
|
+
restart_count?: number | undefined;
|
|
364
|
+
}>;
|
|
365
|
+
export declare const MonitorDashboardSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
309
366
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
CHANGED
|
@@ -125,4 +125,28 @@ export const ListNetworksSchema = z.object({
|
|
|
125
125
|
export const ListVolumesSchema = z.object({
|
|
126
126
|
filter: z.string().optional().describe("Filter by name or driver"),
|
|
127
127
|
});
|
|
128
|
+
// Monitoring schemas (v0.2.0)
|
|
129
|
+
export const ContainerHealthStatusSchema = z.object({});
|
|
130
|
+
export const ContainerResourceUsageSchema = z.object({
|
|
131
|
+
sort_by: z.enum(["cpu", "memory", "network"]).optional().describe("Sort results by metric (default: cpu)"),
|
|
132
|
+
});
|
|
133
|
+
export const WatchEventsSchema = z.object({
|
|
134
|
+
container: z.string().optional().describe("Filter by container name or ID"),
|
|
135
|
+
event_type: z.enum(["start", "stop", "die", "restart", "health_status", "oom", "all"]).optional().describe("Filter by event type (default: all)"),
|
|
136
|
+
since: z.string().optional().describe("Show events since timestamp (e.g., '2026-01-01T00:00:00Z')"),
|
|
137
|
+
duration: z.number().optional().describe("Max seconds to listen (default: 30)"),
|
|
138
|
+
});
|
|
139
|
+
export const SearchLogsSchema = z.object({
|
|
140
|
+
pattern: z.string().describe("Regex or grep pattern to search for"),
|
|
141
|
+
containers: z.array(z.string()).optional().describe("Specific containers to search (default: all running)"),
|
|
142
|
+
tail: z.number().optional().describe("Max lines to scan per container (default: 500)"),
|
|
143
|
+
since: z.string().optional().describe("Only search logs since timestamp"),
|
|
144
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
|
145
|
+
});
|
|
146
|
+
export const ResourceAlertCheckSchema = z.object({
|
|
147
|
+
cpu_percent: z.number().optional().describe("Alert if CPU usage exceeds this % (default: 80)"),
|
|
148
|
+
memory_percent: z.number().optional().describe("Alert if memory usage exceeds this % (default: 80)"),
|
|
149
|
+
restart_count: z.number().optional().describe("Alert if restart count exceeds this (default: 5)"),
|
|
150
|
+
});
|
|
151
|
+
export const MonitorDashboardSchema = z.object({});
|
|
128
152
|
//# sourceMappingURL=types.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supernova123/docker-mcp-server",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"mcpName": "io.github.friendlygeorge/docker-mcp-server",
|
|
5
|
-
"description": "MCP server for Docker
|
|
5
|
+
"description": "MCP server for Docker \u2014 container management, health checks, auto-restart, Compose lifecycle, and log streaming for Claude, Cursor, and AI agents",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -18,19 +18,31 @@
|
|
|
18
18
|
"keywords": [
|
|
19
19
|
"mcp",
|
|
20
20
|
"mcp-server",
|
|
21
|
+
"model-context-protocol",
|
|
21
22
|
"docker",
|
|
22
23
|
"docker-mcp",
|
|
23
24
|
"container",
|
|
25
|
+
"containers",
|
|
24
26
|
"compose",
|
|
27
|
+
"docker-compose",
|
|
25
28
|
"health-check",
|
|
29
|
+
"health-monitoring",
|
|
30
|
+
"container-health",
|
|
26
31
|
"devops",
|
|
32
|
+
"infrastructure",
|
|
33
|
+
"orchestration",
|
|
27
34
|
"ai-agent",
|
|
28
|
-
"
|
|
35
|
+
"ai-agents",
|
|
29
36
|
"claude",
|
|
30
37
|
"cursor",
|
|
31
38
|
"chatgpt",
|
|
32
39
|
"claude-desktop",
|
|
33
|
-
"
|
|
40
|
+
"monitoring",
|
|
41
|
+
"resource-monitoring",
|
|
42
|
+
"log-search",
|
|
43
|
+
"container-logs",
|
|
44
|
+
"docker-events",
|
|
45
|
+
"fleet-management"
|
|
34
46
|
],
|
|
35
47
|
"author": "Nova",
|
|
36
48
|
"license": "MIT",
|
package/src/server.ts
CHANGED
|
@@ -7,11 +7,12 @@ 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 { registerMonitoringTools } from "./tools/monitoring.js";
|
|
10
11
|
|
|
11
12
|
export function createServer(docker: Dockerode): McpServer {
|
|
12
13
|
const server = new McpServer({
|
|
13
14
|
name: "docker-mcp-server",
|
|
14
|
-
version: "0.
|
|
15
|
+
version: "0.2.0",
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
// Register all tool categories
|
|
@@ -22,6 +23,7 @@ export function createServer(docker: Dockerode): McpServer {
|
|
|
22
23
|
registerLogsTools(server, docker);
|
|
23
24
|
registerExecTools(server, docker);
|
|
24
25
|
registerNetworkTools(server, docker);
|
|
26
|
+
registerMonitoringTools(server, docker);
|
|
25
27
|
|
|
26
28
|
return server;
|
|
27
29
|
}
|
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
|
|
12
|
+
export function registerMonitoringTools(server: McpServer, docker: Dockerode): void {
|
|
13
|
+
// 1. fleet_status — health status of all running containers
|
|
14
|
+
server.tool(
|
|
15
|
+
"container_health_status",
|
|
16
|
+
"Check health status, uptime, and restart count for all running Docker containers. Returns JSON with container name, state, health probe status, and restart count.",
|
|
17
|
+
ContainerHealthStatusSchema.shape,
|
|
18
|
+
async (params) => {
|
|
19
|
+
try {
|
|
20
|
+
const containers = await docker.listContainers({ all: false });
|
|
21
|
+
const results = await Promise.all(
|
|
22
|
+
containers.map(async (c) => {
|
|
23
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
24
|
+
return {
|
|
25
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
26
|
+
id: c.Id.slice(0, 12),
|
|
27
|
+
state: c.State,
|
|
28
|
+
status: c.Status,
|
|
29
|
+
health: info.State.Health?.Status || "no-healthcheck",
|
|
30
|
+
uptime: info.State.StartedAt,
|
|
31
|
+
restartCount: info.RestartCount,
|
|
32
|
+
image: c.Image,
|
|
33
|
+
};
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
38
|
+
};
|
|
39
|
+
} catch (err: any) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// 2. fleet_stats — resource usage for all running containers
|
|
49
|
+
server.tool(
|
|
50
|
+
"container_resource_usage",
|
|
51
|
+
"Monitor CPU, memory, and network I/O across all running Docker containers. Returns sorted resource usage metrics with percentage breakdowns.",
|
|
52
|
+
ContainerResourceUsageSchema.shape,
|
|
53
|
+
async (params) => {
|
|
54
|
+
try {
|
|
55
|
+
const containers = await docker.listContainers({ all: false });
|
|
56
|
+
const results = await Promise.all(
|
|
57
|
+
containers.map(async (c) => {
|
|
58
|
+
const stats = await docker.getContainer(c.Id).stats({ stream: false });
|
|
59
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
60
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
61
|
+
const cpuCount = stats.cpu_stats.online_cpus ?? 1;
|
|
62
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
63
|
+
const memUsage = stats.memory_stats?.usage ?? 0;
|
|
64
|
+
const memLimit = stats.memory_stats?.limit ?? 1;
|
|
65
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
66
|
+
const netRx = Object.values(stats.networks ?? {}).reduce((sum: number, n: any) => sum + (n.rx_bytes ?? 0), 0);
|
|
67
|
+
const netTx = Object.values(stats.networks ?? {}).reduce((sum: number, n: any) => sum + (n.tx_bytes ?? 0), 0);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
71
|
+
id: c.Id.slice(0, 12),
|
|
72
|
+
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
|
73
|
+
memory_usage_mb: Math.round((memUsage / 1024 / 1024) * 100) / 100,
|
|
74
|
+
memory_percent: Math.round(memPercent * 100) / 100,
|
|
75
|
+
network_rx_mb: Math.round((netRx / 1024 / 1024) * 100) / 100,
|
|
76
|
+
network_tx_mb: Math.round((netTx / 1024 / 1024) * 100) / 100,
|
|
77
|
+
};
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const sortBy = params.sort_by || "cpu";
|
|
82
|
+
results.sort((a: any, b: any) => {
|
|
83
|
+
if (sortBy === "cpu") return b.cpu_percent - a.cpu_percent;
|
|
84
|
+
if (sortBy === "memory") return b.memory_percent - a.memory_percent;
|
|
85
|
+
return (b.network_rx_mb + b.network_tx_mb) - (a.network_rx_mb + a.network_tx_mb);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
90
|
+
};
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
94
|
+
isError: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// 3. watch_events — stream Docker events (simplified: collect events for a duration)
|
|
101
|
+
server.tool(
|
|
102
|
+
"watch_events",
|
|
103
|
+
"Stream Docker container events (start, stop, die, restart, health_status) over a configurable time window. Filter by specific container or event type.",
|
|
104
|
+
WatchEventsSchema.shape,
|
|
105
|
+
async (params) => {
|
|
106
|
+
try {
|
|
107
|
+
const durationMs = (params.duration || 30) * 1000;
|
|
108
|
+
const filter: any = {};
|
|
109
|
+
if (params.container) filter.container = [params.container];
|
|
110
|
+
if (params.event_type && params.event_type !== "all") filter.event = [params.event_type];
|
|
111
|
+
if (params.since) filter.since = [params.since];
|
|
112
|
+
|
|
113
|
+
const events: any[] = [];
|
|
114
|
+
const stream = await docker.getEvents(filter as Dockerode.GetEventsOptions) as unknown as NodeJS.ReadableStream;
|
|
115
|
+
|
|
116
|
+
await new Promise<void>((resolve) => {
|
|
117
|
+
const timeout = setTimeout(() => {
|
|
118
|
+
resolve();
|
|
119
|
+
}, durationMs);
|
|
120
|
+
|
|
121
|
+
stream.on("data", (chunk: Buffer) => {
|
|
122
|
+
try {
|
|
123
|
+
const event = JSON.parse(chunk.toString());
|
|
124
|
+
events.push({
|
|
125
|
+
type: event.Type,
|
|
126
|
+
action: event.Action,
|
|
127
|
+
container: event.Actor?.Attributes?.name || event.Actor?.ID?.slice(0, 12),
|
|
128
|
+
time: new Date(event.time * 1000).toISOString(),
|
|
129
|
+
});
|
|
130
|
+
} catch {}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
stream.on("error", () => {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
resolve();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
stream.on("end", () => {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
resolve();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: events.length ? JSON.stringify(events, null, 2) : "No events captured in the time window." }],
|
|
146
|
+
};
|
|
147
|
+
} catch (err: any) {
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
150
|
+
isError: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// 4. search_logs — search logs across multiple containers
|
|
157
|
+
server.tool(
|
|
158
|
+
"search_logs",
|
|
159
|
+
"Search Docker container logs across multiple containers using regex pattern matching. Returns matching log lines with container name and timestamp.",
|
|
160
|
+
SearchLogsSchema.shape,
|
|
161
|
+
async (params) => {
|
|
162
|
+
try {
|
|
163
|
+
const targetContainers = params.containers || [];
|
|
164
|
+
let containers: { id: string; name: string }[];
|
|
165
|
+
|
|
166
|
+
if (targetContainers.length > 0) {
|
|
167
|
+
containers = await Promise.all(
|
|
168
|
+
targetContainers.map(async (id) => {
|
|
169
|
+
const info = await docker.getContainer(id).inspect();
|
|
170
|
+
return { id, name: info.Name.replace(/^\//, "") };
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
const list = await docker.listContainers({ all: false });
|
|
175
|
+
containers = list.map((c) => ({ id: c.Id, name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12) }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const regex = new RegExp(params.pattern, params.ignore_case ? "i" : "");
|
|
179
|
+
const matches: any[] = [];
|
|
180
|
+
|
|
181
|
+
for (const container of containers) {
|
|
182
|
+
try {
|
|
183
|
+
const logStream = await docker.getContainer(container.id).logs({
|
|
184
|
+
stdout: true,
|
|
185
|
+
stderr: true,
|
|
186
|
+
tail: params.tail || 500,
|
|
187
|
+
since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
|
|
188
|
+
});
|
|
189
|
+
const output = logStream.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
|
|
190
|
+
const lines = output.split("\n");
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (regex.test(line)) {
|
|
193
|
+
matches.push({ container: container.name, line: line.trim() });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
content: [{ type: "text", text: matches.length ? JSON.stringify(matches, null, 2) : "No matches found." }],
|
|
201
|
+
};
|
|
202
|
+
} catch (err: any) {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
205
|
+
isError: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// 5. check_thresholds — check all containers against thresholds
|
|
212
|
+
server.tool(
|
|
213
|
+
"resource_alert_check",
|
|
214
|
+
"Alert when Docker containers exceed resource thresholds (CPU%, memory%, restart count). Returns violations with specific metrics that triggered alerts.",
|
|
215
|
+
ResourceAlertCheckSchema.shape,
|
|
216
|
+
async (params) => {
|
|
217
|
+
try {
|
|
218
|
+
const cpuThreshold = params.cpu_percent ?? 80;
|
|
219
|
+
const memThreshold = params.memory_percent ?? 80;
|
|
220
|
+
const restartThreshold = params.restart_count ?? 5;
|
|
221
|
+
const containers = await docker.listContainers({ all: false });
|
|
222
|
+
const violations: any[] = [];
|
|
223
|
+
|
|
224
|
+
for (const c of containers) {
|
|
225
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
226
|
+
const issues: string[] = [];
|
|
227
|
+
|
|
228
|
+
// Check restart count
|
|
229
|
+
if (info.RestartCount > restartThreshold) {
|
|
230
|
+
issues.push(`restarts: ${info.RestartCount} > ${restartThreshold}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check CPU and memory
|
|
234
|
+
try {
|
|
235
|
+
const stats = await docker.getContainer(c.Id).stats({ stream: false });
|
|
236
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
237
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
238
|
+
const cpuCount = stats.cpu_stats.online_cpus ?? 1;
|
|
239
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
240
|
+
const memUsage = stats.memory_stats?.usage ?? 0;
|
|
241
|
+
const memLimit = stats.memory_stats?.limit ?? 1;
|
|
242
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
243
|
+
|
|
244
|
+
if (cpuPercent > cpuThreshold) issues.push(`cpu: ${Math.round(cpuPercent)}% > ${cpuThreshold}%`);
|
|
245
|
+
if (memPercent > memThreshold) issues.push(`memory: ${Math.round(memPercent)}% > ${memThreshold}%`);
|
|
246
|
+
} catch {}
|
|
247
|
+
|
|
248
|
+
if (issues.length > 0) {
|
|
249
|
+
violations.push({
|
|
250
|
+
container: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
251
|
+
id: c.Id.slice(0, 12),
|
|
252
|
+
issues,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
content: [{
|
|
259
|
+
type: "text",
|
|
260
|
+
text: violations.length
|
|
261
|
+
? JSON.stringify({ violations, checked: containers.length }, null, 2)
|
|
262
|
+
: JSON.stringify({ message: "All containers within thresholds.", checked: containers.length }),
|
|
263
|
+
}],
|
|
264
|
+
};
|
|
265
|
+
} catch (err: any) {
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
268
|
+
isError: true,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// 6. monitor_dashboard — single-call fleet summary
|
|
275
|
+
server.tool(
|
|
276
|
+
"monitor_dashboard",
|
|
277
|
+
"Comprehensive Docker fleet dashboard in a single API call. Returns health status, top resource consumers, recent events, and threshold violations.",
|
|
278
|
+
MonitorDashboardSchema.shape,
|
|
279
|
+
async (params) => {
|
|
280
|
+
try {
|
|
281
|
+
const containers = await docker.listContainers({ all: false });
|
|
282
|
+
|
|
283
|
+
// Fleet health
|
|
284
|
+
const health = await Promise.all(
|
|
285
|
+
containers.map(async (c) => {
|
|
286
|
+
const info = await docker.getContainer(c.Id).inspect();
|
|
287
|
+
return {
|
|
288
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
289
|
+
state: c.State,
|
|
290
|
+
health: info.State.Health?.Status || "no-healthcheck",
|
|
291
|
+
restartCount: info.RestartCount,
|
|
292
|
+
};
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Resource usage (top 5 by CPU)
|
|
297
|
+
const stats = await Promise.all(
|
|
298
|
+
containers.map(async (c) => {
|
|
299
|
+
try {
|
|
300
|
+
const s = await docker.getContainer(c.Id).stats({ stream: false });
|
|
301
|
+
const cpuDelta = s.cpu_stats.cpu_usage.total_usage - (s.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
|
302
|
+
const systemDelta = s.cpu_stats.system_cpu_usage - (s.precpu_stats?.system_cpu_usage ?? 0);
|
|
303
|
+
const cpuCount = s.cpu_stats.online_cpus ?? 1;
|
|
304
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
|
|
305
|
+
const memUsage = s.memory_stats?.usage ?? 0;
|
|
306
|
+
const memLimit = s.memory_stats?.limit ?? 1;
|
|
307
|
+
const memPercent = (memUsage / memLimit) * 100;
|
|
308
|
+
return {
|
|
309
|
+
name: c.Names[0]?.replace(/^\//, "") || c.Id.slice(0, 12),
|
|
310
|
+
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
|
311
|
+
memory_percent: Math.round(memPercent * 100) / 100,
|
|
312
|
+
};
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const topConsumers = stats.filter(Boolean).sort((a: any, b: any) => b.cpu_percent - a.cpu_percent).slice(0, 5);
|
|
320
|
+
|
|
321
|
+
// Recent events (last 5 minutes) - use simple approach
|
|
322
|
+
const recentEvents: any[] = [];
|
|
323
|
+
try {
|
|
324
|
+
const sinceTs = Math.floor((Date.now() - 5 * 60 * 1000) / 1000);
|
|
325
|
+
const eventStream = await docker.getEvents({ since: sinceTs }) as unknown as NodeJS.ReadableStream;
|
|
326
|
+
await new Promise<void>((resolve) => {
|
|
327
|
+
const timeout = setTimeout(() => { resolve(); }, 2000);
|
|
328
|
+
eventStream.on("data", (chunk: Buffer) => {
|
|
329
|
+
try {
|
|
330
|
+
const e = JSON.parse(chunk.toString());
|
|
331
|
+
recentEvents.push({
|
|
332
|
+
action: e.Action,
|
|
333
|
+
container: e.Actor?.Attributes?.name || e.Actor?.ID?.slice(0, 12),
|
|
334
|
+
time: new Date(e.time * 1000).toISOString(),
|
|
335
|
+
});
|
|
336
|
+
} catch {}
|
|
337
|
+
});
|
|
338
|
+
eventStream.on("error", () => { clearTimeout(timeout); resolve(); });
|
|
339
|
+
eventStream.on("end", () => { clearTimeout(timeout); resolve(); });
|
|
340
|
+
});
|
|
341
|
+
} catch {}
|
|
342
|
+
|
|
343
|
+
// Threshold violations
|
|
344
|
+
const violations = stats.filter(Boolean).filter((s: any) => s.cpu_percent > 80 || s.memory_percent > 80);
|
|
345
|
+
|
|
346
|
+
const dashboard = {
|
|
347
|
+
summary: {
|
|
348
|
+
total_containers: containers.length,
|
|
349
|
+
running: containers.filter((c) => c.State === "running").length,
|
|
350
|
+
unhealthy: health.filter((h) => h.health === "unhealthy").length,
|
|
351
|
+
},
|
|
352
|
+
health,
|
|
353
|
+
top_cpu_consumers: topConsumers,
|
|
354
|
+
recent_events: recentEvents.slice(0, 10),
|
|
355
|
+
threshold_violations: violations,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: "text", text: JSON.stringify(dashboard, null, 2) }],
|
|
360
|
+
};
|
|
361
|
+
} catch (err: any) {
|
|
362
|
+
return {
|
|
363
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
364
|
+
isError: true,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -150,3 +150,34 @@ export const ListNetworksSchema = z.object({
|
|
|
150
150
|
export const ListVolumesSchema = z.object({
|
|
151
151
|
filter: z.string().optional().describe("Filter by name or driver"),
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
// Monitoring schemas (v0.2.0)
|
|
156
|
+
export const ContainerHealthStatusSchema = z.object({});
|
|
157
|
+
|
|
158
|
+
export const ContainerResourceUsageSchema = z.object({
|
|
159
|
+
sort_by: z.enum(["cpu", "memory", "network"]).optional().describe("Sort results by metric (default: cpu)"),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const WatchEventsSchema = z.object({
|
|
163
|
+
container: z.string().optional().describe("Filter by container name or ID"),
|
|
164
|
+
event_type: z.enum(["start", "stop", "die", "restart", "health_status", "oom", "all"]).optional().describe("Filter by event type (default: all)"),
|
|
165
|
+
since: z.string().optional().describe("Show events since timestamp (e.g., '2026-01-01T00:00:00Z')"),
|
|
166
|
+
duration: z.number().optional().describe("Max seconds to listen (default: 30)"),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const SearchLogsSchema = z.object({
|
|
170
|
+
pattern: z.string().describe("Regex or grep pattern to search for"),
|
|
171
|
+
containers: z.array(z.string()).optional().describe("Specific containers to search (default: all running)"),
|
|
172
|
+
tail: z.number().optional().describe("Max lines to scan per container (default: 500)"),
|
|
173
|
+
since: z.string().optional().describe("Only search logs since timestamp"),
|
|
174
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const ResourceAlertCheckSchema = z.object({
|
|
178
|
+
cpu_percent: z.number().optional().describe("Alert if CPU usage exceeds this % (default: 80)"),
|
|
179
|
+
memory_percent: z.number().optional().describe("Alert if memory usage exceeds this % (default: 80)"),
|
|
180
|
+
restart_count: z.number().optional().describe("Alert if restart count exceeds this (default: 5)"),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const MonitorDashboardSchema = z.object({});
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock Dockerode before importing the module under test
|
|
4
|
+
const mockListContainers = vi.fn();
|
|
5
|
+
const mockInspect = vi.fn();
|
|
6
|
+
const mockStats = vi.fn();
|
|
7
|
+
const mockGetEvents = vi.fn();
|
|
8
|
+
const mockLogs = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("dockerode", () => {
|
|
11
|
+
return {
|
|
12
|
+
default: vi.fn().mockImplementation(() => ({
|
|
13
|
+
listContainers: mockListContainers,
|
|
14
|
+
getContainer: vi.fn().mockReturnValue({
|
|
15
|
+
inspect: mockInspect,
|
|
16
|
+
stats: mockStats,
|
|
17
|
+
logs: mockLogs,
|
|
18
|
+
}),
|
|
19
|
+
getEvents: mockGetEvents,
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { registerMonitoringTools } from "../src/tools/monitoring.js";
|
|
25
|
+
|
|
26
|
+
// Minimal MCP server mock (same pattern as container.test.ts)
|
|
27
|
+
function createMockServer() {
|
|
28
|
+
const tools: Record<string, { description: string; handler: Function }> = {};
|
|
29
|
+
return {
|
|
30
|
+
tool: (name: string, description: string, _schema: unknown, handler: Function) => {
|
|
31
|
+
tools[name] = { description, handler };
|
|
32
|
+
},
|
|
33
|
+
tools,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper: mock container list
|
|
38
|
+
function mockContainers(ids: string[], names: string[]) {
|
|
39
|
+
mockListContainers.mockResolvedValue(
|
|
40
|
+
ids.map((id, i) => ({
|
|
41
|
+
Id: id,
|
|
42
|
+
Names: [names[i] || `/container-${i}`],
|
|
43
|
+
Image: "nginx:latest",
|
|
44
|
+
State: "running",
|
|
45
|
+
Status: "Up 1 hour",
|
|
46
|
+
}))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Helper: mock inspect result
|
|
51
|
+
function mockInspectResult(overrides: Record<string, any> = {}) {
|
|
52
|
+
return {
|
|
53
|
+
Id: "abc123",
|
|
54
|
+
State: {
|
|
55
|
+
Running: true,
|
|
56
|
+
StartedAt: "2026-06-15T10:00:00Z",
|
|
57
|
+
Health: { Status: "healthy" },
|
|
58
|
+
},
|
|
59
|
+
RestartCount: 0,
|
|
60
|
+
Name: "/test-container",
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Helper: mock stats result
|
|
66
|
+
function mockStatsResult(overrides: Record<string, any> = {}) {
|
|
67
|
+
return {
|
|
68
|
+
cpu_stats: {
|
|
69
|
+
cpu_usage: { total_usage: 1000000 },
|
|
70
|
+
system_cpu_usage: 10000000,
|
|
71
|
+
online_cpus: 2,
|
|
72
|
+
},
|
|
73
|
+
precpu_stats: {
|
|
74
|
+
cpu_usage: { total_usage: 900000 },
|
|
75
|
+
system_cpu_usage: 9500000,
|
|
76
|
+
},
|
|
77
|
+
memory_stats: {
|
|
78
|
+
usage: 100 * 1024 * 1024, // 100MB
|
|
79
|
+
limit: 1024 * 1024 * 1024, // 1GB
|
|
80
|
+
},
|
|
81
|
+
networks: {
|
|
82
|
+
eth0: { rx_bytes: 1024 * 1024, tx_bytes: 512 * 1024 },
|
|
83
|
+
},
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("Monitoring Tools", () => {
|
|
89
|
+
let server: ReturnType<typeof createMockServer>;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.restoreAllMocks();
|
|
93
|
+
server = createMockServer();
|
|
94
|
+
// Create a fresh docker-like object with direct mock references
|
|
95
|
+
const docker = {
|
|
96
|
+
listContainers: mockListContainers,
|
|
97
|
+
getContainer: (id: string) => ({
|
|
98
|
+
inspect: mockInspect,
|
|
99
|
+
stats: mockStats,
|
|
100
|
+
logs: mockLogs,
|
|
101
|
+
}),
|
|
102
|
+
getEvents: mockGetEvents,
|
|
103
|
+
} as any;
|
|
104
|
+
registerMonitoringTools(server, docker);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("container_health_status", () => {
|
|
108
|
+
it("returns health status for all running containers", async () => {
|
|
109
|
+
mockContainers(["abc123", "def456"], ["web", "db"]);
|
|
110
|
+
mockInspect
|
|
111
|
+
.mockResolvedValueOnce(mockInspectResult({ RestartCount: 2 }))
|
|
112
|
+
.mockResolvedValueOnce(mockInspectResult({ RestartCount: 0, State: { Running: true, StartedAt: "2026-06-15T11:00:00Z", Health: { Status: "unhealthy" } } }));
|
|
113
|
+
|
|
114
|
+
const result = await server.tools["container_health_status"].handler({});
|
|
115
|
+
const data = JSON.parse(result.content[0].text);
|
|
116
|
+
|
|
117
|
+
expect(data).toHaveLength(2);
|
|
118
|
+
expect(data[0].name).toBe("web");
|
|
119
|
+
expect(data[0].id).toBe("abc123");
|
|
120
|
+
expect(data[0].health).toBe("healthy");
|
|
121
|
+
expect(data[0].restartCount).toBe(2);
|
|
122
|
+
expect(data[1].health).toBe("unhealthy");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("handles containers with no healthcheck", async () => {
|
|
126
|
+
mockContainers(["abc123"], ["no-health"]);
|
|
127
|
+
mockInspect.mockResolvedValue(mockInspectResult({
|
|
128
|
+
State: { Running: true, StartedAt: "2026-06-15T10:00:00Z" },
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const result = await server.tools["container_health_status"].handler({});
|
|
132
|
+
const data = JSON.parse(result.content[0].text);
|
|
133
|
+
|
|
134
|
+
expect(data[0].health).toBe("no-healthcheck");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns error on Docker failure", async () => {
|
|
138
|
+
mockListContainers.mockRejectedValue(new Error("Docker daemon not running"));
|
|
139
|
+
const result = await server.tools["container_health_status"].handler({});
|
|
140
|
+
|
|
141
|
+
expect(result.isError).toBe(true);
|
|
142
|
+
expect(result.content[0].text).toContain("Docker daemon not running");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("container_resource_usage", () => {
|
|
147
|
+
it("returns resource usage sorted by CPU by default", async () => {
|
|
148
|
+
mockContainers(["abc123", "def456"], ["low-cpu", "high-cpu"]);
|
|
149
|
+
mockStats
|
|
150
|
+
.mockResolvedValueOnce(mockStatsResult({
|
|
151
|
+
cpu_stats: { cpu_usage: { total_usage: 100000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
152
|
+
precpu_stats: { cpu_usage: { total_usage: 90000 }, system_cpu_usage: 9500000 },
|
|
153
|
+
}))
|
|
154
|
+
.mockResolvedValueOnce(mockStatsResult({
|
|
155
|
+
cpu_stats: { cpu_usage: { total_usage: 500000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
156
|
+
precpu_stats: { cpu_usage: { total_usage: 100000 }, system_cpu_usage: 9500000 },
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const result = await server.tools["container_resource_usage"].handler({});
|
|
160
|
+
const data = JSON.parse(result.content[0].text);
|
|
161
|
+
|
|
162
|
+
expect(data).toHaveLength(2);
|
|
163
|
+
// high-cpu should be first (sorted by CPU desc)
|
|
164
|
+
expect(data[0].name).toBe("high-cpu");
|
|
165
|
+
expect(data[0].cpu_percent).toBeGreaterThan(data[1].cpu_percent);
|
|
166
|
+
expect(data[0].memory_usage_mb).toBeGreaterThan(0);
|
|
167
|
+
expect(data[0].network_rx_mb).toBeGreaterThanOrEqual(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("sorts by memory when requested", async () => {
|
|
171
|
+
mockContainers(["abc123", "def456"], ["low-mem", "high-mem"]);
|
|
172
|
+
mockStats
|
|
173
|
+
.mockResolvedValueOnce(mockStatsResult({
|
|
174
|
+
memory_stats: { usage: 50 * 1024 * 1024, limit: 1024 * 1024 * 1024 },
|
|
175
|
+
}))
|
|
176
|
+
.mockResolvedValueOnce(mockStatsResult({
|
|
177
|
+
memory_stats: { usage: 900 * 1024 * 1024, limit: 1024 * 1024 * 1024 },
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
const result = await server.tools["container_resource_usage"].handler({ sort_by: "memory" });
|
|
181
|
+
const data = JSON.parse(result.content[0].text);
|
|
182
|
+
|
|
183
|
+
expect(data[0].name).toBe("high-mem");
|
|
184
|
+
expect(data[0].memory_percent).toBeGreaterThan(data[1].memory_percent);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns error on Docker failure", async () => {
|
|
188
|
+
mockListContainers.mockRejectedValue(new Error("Cannot connect"));
|
|
189
|
+
const result = await server.tools["container_resource_usage"].handler({});
|
|
190
|
+
|
|
191
|
+
expect(result.isError).toBe(true);
|
|
192
|
+
expect(result.content[0].text).toContain("Cannot connect");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("watch_events", () => {
|
|
197
|
+
it("collects events within time window", async () => {
|
|
198
|
+
const { Readable } = await import("stream");
|
|
199
|
+
const event1 = JSON.stringify({ Type: "container", Action: "start", Actor: { Attributes: { name: "web" }, ID: "abc123" }, time: Math.floor(Date.now() / 1000) });
|
|
200
|
+
const event2 = JSON.stringify({ Type: "container", Action: "stop", Actor: { Attributes: { name: "web" }, ID: "abc123" }, time: Math.floor(Date.now() / 1000) });
|
|
201
|
+
|
|
202
|
+
const stream = new Readable({
|
|
203
|
+
read() {
|
|
204
|
+
this.push(event1 + "\n");
|
|
205
|
+
this.push(event2 + "\n");
|
|
206
|
+
this.push(null); // end stream
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
mockGetEvents.mockResolvedValue(stream);
|
|
210
|
+
|
|
211
|
+
const result = await server.tools["watch_events"].handler({ duration: 5 });
|
|
212
|
+
const data = JSON.parse(result.content[0].text);
|
|
213
|
+
|
|
214
|
+
expect(data).toHaveLength(2);
|
|
215
|
+
expect(data[0].type).toBe("container");
|
|
216
|
+
expect(data[0].action).toBe("start");
|
|
217
|
+
expect(data[0].container).toBe("web");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns message when no events captured", async () => {
|
|
221
|
+
const { Readable } = await import("stream");
|
|
222
|
+
const stream = new Readable({ read() { this.push(null); } });
|
|
223
|
+
mockGetEvents.mockResolvedValue(stream);
|
|
224
|
+
|
|
225
|
+
const result = await server.tools["watch_events"].handler({ duration: 1 });
|
|
226
|
+
expect(result.content[0].text).toBe("No events captured in the time window.");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns error on Docker failure", async () => {
|
|
230
|
+
mockGetEvents.mockRejectedValue(new Error("Docker socket not found"));
|
|
231
|
+
const result = await server.tools["watch_events"].handler({});
|
|
232
|
+
|
|
233
|
+
expect(result.isError).toBe(true);
|
|
234
|
+
expect(result.content[0].text).toContain("Docker socket not found");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("search_logs", () => {
|
|
239
|
+
it("searches logs with regex pattern", async () => {
|
|
240
|
+
mockContainers(["abc123"], ["web"]);
|
|
241
|
+
mockInspect.mockResolvedValue(mockInspectResult());
|
|
242
|
+
// Docker logs return Buffer with 8-byte header per line
|
|
243
|
+
const logLines = Buffer.from("2026-06-15 ERROR connection refused\n2026-06-15 INFO server started\n2026-06-15 ERROR timeout\n");
|
|
244
|
+
mockLogs.mockResolvedValue(logLines);
|
|
245
|
+
|
|
246
|
+
const result = await server.tools["search_logs"].handler({ pattern: "ERROR" });
|
|
247
|
+
const data = JSON.parse(result.content[0].text);
|
|
248
|
+
|
|
249
|
+
expect(data).toHaveLength(2);
|
|
250
|
+
expect(data[0].container).toBe("web");
|
|
251
|
+
expect(data[0].line).toContain("ERROR");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("searches across multiple containers when no specific containers given", async () => {
|
|
255
|
+
mockContainers(["abc123", "def456"], ["web", "db"]);
|
|
256
|
+
mockInspect
|
|
257
|
+
.mockResolvedValueOnce(mockInspectResult())
|
|
258
|
+
.mockResolvedValueOnce(mockInspectResult());
|
|
259
|
+
mockLogs
|
|
260
|
+
.mockResolvedValueOnce(Buffer.from("2026-06-15 ERROR web error\n"))
|
|
261
|
+
.mockResolvedValueOnce(Buffer.from("2026-06-15 INFO db ready\n"));
|
|
262
|
+
|
|
263
|
+
const result = await server.tools["search_logs"].handler({ pattern: "ERROR" });
|
|
264
|
+
const data = JSON.parse(result.content[0].text);
|
|
265
|
+
|
|
266
|
+
expect(data).toHaveLength(1);
|
|
267
|
+
expect(data[0].container).toBe("web");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("returns no matches message when pattern not found", async () => {
|
|
271
|
+
mockContainers(["abc123"], ["web"]);
|
|
272
|
+
mockInspect.mockResolvedValue(mockInspectResult());
|
|
273
|
+
mockLogs.mockResolvedValue(Buffer.from("2026-06-15 INFO all good\n"));
|
|
274
|
+
|
|
275
|
+
const result = await server.tools["search_logs"].handler({ pattern: "CRITICAL" });
|
|
276
|
+
expect(result.content[0].text).toBe("No matches found.");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("supports case-insensitive matching", async () => {
|
|
280
|
+
mockContainers(["abc123"], ["web"]);
|
|
281
|
+
mockInspect.mockResolvedValue(mockInspectResult());
|
|
282
|
+
mockLogs.mockResolvedValue(Buffer.from("2026-06-15 Error lowercase\n"));
|
|
283
|
+
|
|
284
|
+
const result = await server.tools["search_logs"].handler({ pattern: "error", ignore_case: true });
|
|
285
|
+
const data = JSON.parse(result.content[0].text);
|
|
286
|
+
|
|
287
|
+
expect(data).toHaveLength(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("returns error on Docker failure", async () => {
|
|
291
|
+
mockListContainers.mockRejectedValue(new Error("Cannot connect"));
|
|
292
|
+
const result = await server.tools["search_logs"].handler({ pattern: "ERROR" });
|
|
293
|
+
|
|
294
|
+
expect(result.isError).toBe(true);
|
|
295
|
+
expect(result.content[0].text).toContain("Cannot connect");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("resource_alert_check", () => {
|
|
300
|
+
it("returns violations when containers exceed thresholds", async () => {
|
|
301
|
+
mockContainers(["abc123"], ["high-cpu"]);
|
|
302
|
+
mockInspect.mockResolvedValue(mockInspectResult({ RestartCount: 10 }));
|
|
303
|
+
mockStats.mockResolvedValue(mockStatsResult({
|
|
304
|
+
cpu_stats: { cpu_usage: { total_usage: 9000000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
305
|
+
precpu_stats: { cpu_usage: { total_usage: 1000000 }, system_cpu_usage: 9500000 },
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
const result = await server.tools["resource_alert_check"].handler({ cpu_percent: 80, restart_count: 5 });
|
|
309
|
+
const data = JSON.parse(result.content[0].text);
|
|
310
|
+
|
|
311
|
+
expect(data.violations).toHaveLength(1);
|
|
312
|
+
expect(data.violations[0].container).toBe("high-cpu");
|
|
313
|
+
expect(data.violations[0].issues.length).toBeGreaterThan(0);
|
|
314
|
+
expect(data.violations[0].issues.some((i: string) => i.includes("restarts"))).toBe(true);
|
|
315
|
+
expect(data.violations[0].issues.some((i: string) => i.includes("cpu"))).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("returns all-clear when within thresholds", async () => {
|
|
319
|
+
mockContainers(["abc123"], ["healthy"]);
|
|
320
|
+
mockInspect.mockResolvedValue(mockInspectResult({ RestartCount: 0 }));
|
|
321
|
+
mockStats.mockResolvedValue(mockStatsResult({
|
|
322
|
+
cpu_stats: { cpu_usage: { total_usage: 100000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
323
|
+
precpu_stats: { cpu_usage: { total_usage: 90000 }, system_cpu_usage: 9500000 },
|
|
324
|
+
}));
|
|
325
|
+
|
|
326
|
+
const result = await server.tools["resource_alert_check"].handler({});
|
|
327
|
+
const data = JSON.parse(result.content[0].text);
|
|
328
|
+
|
|
329
|
+
expect(data.message).toContain("All containers within thresholds");
|
|
330
|
+
expect(data.checked).toBe(1);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("uses default thresholds when not specified", async () => {
|
|
334
|
+
mockContainers(["abc123"], ["ok"]);
|
|
335
|
+
mockInspect.mockResolvedValue(mockInspectResult({ RestartCount: 4 })); // < 5 default
|
|
336
|
+
mockStats.mockResolvedValue(mockStatsResult({
|
|
337
|
+
cpu_stats: { cpu_usage: { total_usage: 100000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
338
|
+
precpu_stats: { cpu_usage: { total_usage: 90000 }, system_cpu_usage: 9500000 },
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
const result = await server.tools["resource_alert_check"].handler({});
|
|
342
|
+
const data = JSON.parse(result.content[0].text);
|
|
343
|
+
|
|
344
|
+
expect(data.message).toContain("All containers within thresholds");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("returns error on Docker failure", async () => {
|
|
348
|
+
mockListContainers.mockRejectedValue(new Error("Daemon down"));
|
|
349
|
+
const result = await server.tools["resource_alert_check"].handler({});
|
|
350
|
+
|
|
351
|
+
expect(result.isError).toBe(true);
|
|
352
|
+
expect(result.content[0].text).toContain("Daemon down");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("monitor_dashboard", () => {
|
|
357
|
+
it("returns comprehensive fleet summary", async () => {
|
|
358
|
+
mockContainers(["abc123", "def456"], ["web", "db"]);
|
|
359
|
+
mockInspect
|
|
360
|
+
.mockResolvedValueOnce(mockInspectResult({ RestartCount: 0 }))
|
|
361
|
+
.mockResolvedValueOnce(mockInspectResult({ RestartCount: 3, State: { Running: true, StartedAt: "2026-06-15T10:00:00Z", Health: { Status: "unhealthy" } } }));
|
|
362
|
+
mockStats
|
|
363
|
+
.mockResolvedValueOnce(mockStatsResult({
|
|
364
|
+
cpu_stats: { cpu_usage: { total_usage: 200000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
365
|
+
precpu_stats: { cpu_usage: { total_usage: 100000 }, system_cpu_usage: 9500000 },
|
|
366
|
+
}))
|
|
367
|
+
.mockResolvedValueOnce(mockStatsResult({
|
|
368
|
+
cpu_stats: { cpu_usage: { total_usage: 5000000 }, system_cpu_usage: 10000000, online_cpus: 2 },
|
|
369
|
+
precpu_stats: { cpu_usage: { total_usage: 1000000 }, system_cpu_usage: 9500000 },
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
// Mock events (empty stream)
|
|
373
|
+
const { Readable } = await import("stream");
|
|
374
|
+
const eventStream = new Readable({ read() { this.push(null); } });
|
|
375
|
+
mockGetEvents.mockResolvedValue(eventStream);
|
|
376
|
+
|
|
377
|
+
const result = await server.tools["monitor_dashboard"].handler({});
|
|
378
|
+
const data = JSON.parse(result.content[0].text);
|
|
379
|
+
|
|
380
|
+
expect(data.summary.total_containers).toBe(2);
|
|
381
|
+
expect(data.summary.running).toBe(2);
|
|
382
|
+
expect(data.summary.unhealthy).toBe(1);
|
|
383
|
+
expect(data.health).toHaveLength(2);
|
|
384
|
+
expect(data.top_cpu_consumers).toHaveLength(2);
|
|
385
|
+
// db should be first (higher CPU)
|
|
386
|
+
expect(data.top_cpu_consumers[0].name).toBe("db");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("handles empty fleet", async () => {
|
|
390
|
+
mockListContainers.mockResolvedValue([]);
|
|
391
|
+
const { Readable } = await import("stream");
|
|
392
|
+
const eventStream = new Readable({ read() { this.push(null); } });
|
|
393
|
+
mockGetEvents.mockResolvedValue(eventStream);
|
|
394
|
+
|
|
395
|
+
const result = await server.tools["monitor_dashboard"].handler({});
|
|
396
|
+
const data = JSON.parse(result.content[0].text);
|
|
397
|
+
|
|
398
|
+
expect(data.summary.total_containers).toBe(0);
|
|
399
|
+
expect(data.summary.running).toBe(0);
|
|
400
|
+
expect(data.health).toHaveLength(0);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("returns error on Docker failure", async () => {
|
|
404
|
+
mockListContainers.mockRejectedValue(new Error("Connection refused"));
|
|
405
|
+
const result = await server.tools["monitor_dashboard"].handler({});
|
|
406
|
+
|
|
407
|
+
expect(result.isError).toBe(true);
|
|
408
|
+
expect(result.content[0].text).toContain("Connection refused");
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|