Skip to content

Commit 77ec9fd

Browse files
Merge pull request #2 from raythurman2386/fix/docker-metrics-cgroupv2
fix: replace gopsutil docker with CLI calls for cgroups v2 compatibility
2 parents d32ee76 + 7bdb5ec commit 77ec9fd

2 files changed

Lines changed: 146 additions & 36 deletions

File tree

internal/handlers/handlers.go

Lines changed: 91 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/mark3labs/mcp-go/server"
1818
"github.com/shirou/gopsutil/v3/cpu"
1919
"github.com/shirou/gopsutil/v3/disk"
20-
"github.com/shirou/gopsutil/v3/docker"
2120
"github.com/shirou/gopsutil/v3/host"
2221
"github.com/shirou/gopsutil/v3/load"
2322
"github.com/shirou/gopsutil/v3/mem"
@@ -109,8 +108,8 @@ func (h *HandlerManager) RegisterTools(s *server.MCPServer) {
109108

110109
// Docker metrics tool
111110
s.AddTool(mcp.NewTool("get_docker_metrics",
112-
mcp.WithDescription("Get Docker container metrics including CPU and memory usage via cgroups"),
113-
mcp.WithString("container_id", mcp.Description("Optional container ID to filter results"))),
111+
mcp.WithDescription("Get Docker container metrics including CPU, memory, network, and block I/O usage"),
112+
mcp.WithString("container_id", mcp.Description("Optional container ID or name to filter results"))),
114113
h.HandleGetDockerMetrics)
115114

116115
// Network connections tool
@@ -713,7 +712,8 @@ func (h *HandlerManager) HandleGetSystemHealth(ctx context.Context, request mcp.
713712
return mcp.NewToolResultText(string(jsonBytes)), nil
714713
}
715714

716-
// HandleGetDockerMetrics returns Docker container metrics
715+
// HandleGetDockerMetrics returns Docker container metrics using the docker CLI.
716+
// This approach works with both cgroups v1 and v2 systems.
717717
func (h *HandlerManager) HandleGetDockerMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
718718
var containerFilter string
719719

@@ -723,46 +723,104 @@ func (h *HandlerManager) HandleGetDockerMetrics(ctx context.Context, request mcp
723723
}
724724
}
725725

726-
// Get Docker container stats
727-
containers, err := docker.GetDockerStat()
726+
// Verify docker is available
727+
if _, err := exec.LookPath("docker"); err != nil {
728+
return mcp.NewToolResultError(fmt.Sprintf("Docker CLI not found: %v", err)), nil
729+
}
730+
731+
// Get container list via docker ps
732+
psArgs := []string{"ps", "-a", "--no-trunc", "--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}|{{.State}}"}
733+
psOut, err := exec.CommandContext(ctx, "docker", psArgs...).Output()
728734
if err != nil {
729-
return mcp.NewToolResultError(fmt.Sprintf("Docker not available or no containers found: %v", err)), nil
735+
return mcp.NewToolResultError(fmt.Sprintf("Failed to list Docker containers: %v", err)), nil
730736
}
731737

732-
containerData := []map[string]interface{}{}
733-
for _, c := range containers {
734-
// If a specific container is requested, filter
735-
if containerFilter != "" && c.ContainerID != containerFilter && c.Name != containerFilter {
738+
// Parse container list
739+
type containerInfo struct {
740+
id string
741+
name string
742+
image string
743+
status string
744+
running bool
745+
}
746+
var containers []containerInfo
747+
for _, line := range strings.Split(strings.TrimSpace(string(psOut)), "\n") {
748+
if line == "" {
736749
continue
737750
}
738-
739-
cInfo := map[string]interface{}{
740-
"container_id": c.ContainerID,
741-
"name": c.Name,
742-
"image": c.Image,
743-
"status": c.Status,
744-
"running": c.Running,
751+
cols := strings.SplitN(line, "|", 5)
752+
if len(cols) != 5 {
753+
continue
754+
}
755+
c := containerInfo{
756+
id: cols[0],
757+
name: cols[1],
758+
image: cols[2],
759+
status: cols[3],
760+
running: strings.EqualFold(cols[4], "running"),
761+
}
762+
// Client-side filtering by container ID or name
763+
if containerFilter != "" && c.id != containerFilter && c.name != containerFilter &&
764+
!strings.HasPrefix(c.id, containerFilter) {
765+
continue
745766
}
767+
containers = append(containers, c)
768+
}
746769

747-
// Try to get CPU stats for this container
748-
cpuStat, err := docker.CgroupCPU(c.ContainerID, "")
770+
// Get live stats via docker stats for running containers
771+
type statsInfo struct {
772+
cpuPerc string
773+
memUsage string
774+
memPerc string
775+
netIO string
776+
blockIO string
777+
pids string
778+
}
779+
statsMap := make(map[string]statsInfo)
780+
781+
// Only fetch stats if we have containers
782+
if len(containers) > 0 {
783+
statsArgs := []string{"stats", "--no-stream", "--no-trunc", "--format", "{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}"}
784+
statsOut, err := exec.CommandContext(ctx, "docker", statsArgs...).Output()
749785
if err == nil {
750-
cInfo["cpu"] = map[string]interface{}{
751-
"user": cpuStat.User,
752-
"system": cpuStat.System,
753-
"usage": cpuStat.Usage,
786+
for _, line := range strings.Split(strings.TrimSpace(string(statsOut)), "\n") {
787+
if line == "" {
788+
continue
789+
}
790+
cols := strings.SplitN(line, "|", 7)
791+
if len(cols) != 7 {
792+
continue
793+
}
794+
statsMap[cols[0]] = statsInfo{
795+
cpuPerc: strings.TrimSpace(cols[1]),
796+
memUsage: strings.TrimSpace(cols[2]),
797+
memPerc: strings.TrimSpace(cols[3]),
798+
netIO: strings.TrimSpace(cols[4]),
799+
blockIO: strings.TrimSpace(cols[5]),
800+
pids: strings.TrimSpace(cols[6]),
801+
}
754802
}
755803
}
804+
}
756805

757-
// Try to get memory stats for this container
758-
memStat, err := docker.CgroupMem(c.ContainerID, "")
759-
if err == nil {
760-
cInfo["memory"] = map[string]interface{}{
761-
"cache": memStat.Cache,
762-
"rss": memStat.RSS,
763-
"rss_human": config.BytesToHuman(memStat.RSS),
764-
"mapped_file": memStat.MappedFile,
765-
}
806+
// Build result
807+
containerData := []map[string]interface{}{}
808+
for _, c := range containers {
809+
cInfo := map[string]interface{}{
810+
"container_id": c.id,
811+
"name": c.name,
812+
"image": c.image,
813+
"status": c.status,
814+
"running": c.running,
815+
}
816+
817+
if stats, ok := statsMap[c.id]; ok {
818+
cInfo["cpu_percent"] = stats.cpuPerc
819+
cInfo["memory_usage"] = stats.memUsage
820+
cInfo["memory_percent"] = stats.memPerc
821+
cInfo["network_io"] = stats.netIO
822+
cInfo["block_io"] = stats.blockIO
823+
cInfo["pids"] = stats.pids
766824
}
767825

768826
containerData = append(containerData, cInfo)

internal/handlers/handlers_test.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,15 @@ func TestHandleGetDockerMetrics(t *testing.T) {
135135
if err != nil {
136136
t.Fatalf("Handler returned error: %v", err)
137137
}
138-
// Docker may not be available — either we get a tool error or valid JSON
138+
// Docker CLI may not be available — either we get a tool error or valid JSON
139139
textContent, ok := res.Content[0].(mcp.TextContent)
140140
if !ok {
141141
t.Fatalf("Result content not TextContent: %T", res.Content[0])
142142
}
143143
if res.IsError {
144144
// Graceful degradation: Docker not available
145-
if !strings.Contains(textContent.Text, "Docker not available") {
146-
t.Errorf("Expected Docker unavailable message, got: %s", textContent.Text)
145+
if !strings.Contains(textContent.Text, "Docker") {
146+
t.Errorf("Expected Docker-related error message, got: %s", textContent.Text)
147147
}
148148
return
149149
}
@@ -158,6 +158,58 @@ func TestHandleGetDockerMetrics(t *testing.T) {
158158
if _, ok := data["total"]; !ok {
159159
t.Error("Missing 'total' key in Docker metrics result")
160160
}
161+
// Verify containers is an array
162+
containers, ok := data["containers"].([]interface{})
163+
if !ok {
164+
t.Fatal("'containers' is not an array")
165+
}
166+
// If containers exist, verify structure
167+
for i, c := range containers {
168+
cMap, ok := c.(map[string]interface{})
169+
if !ok {
170+
t.Errorf("Container %d is not an object", i)
171+
continue
172+
}
173+
for _, key := range []string{"container_id", "name", "image", "status", "running"} {
174+
if _, ok := cMap[key]; !ok {
175+
t.Errorf("Container %d missing key %q", i, key)
176+
}
177+
}
178+
}
179+
}
180+
181+
func TestHandleGetDockerMetricsWithFilter(t *testing.T) {
182+
h := NewHandlerManager(&config.Config{})
183+
req := mcp.CallToolRequest{
184+
Params: mcp.CallToolParams{
185+
Arguments: map[string]interface{}{
186+
"container_id": "nonexistent-container-12345",
187+
},
188+
},
189+
}
190+
res, err := h.HandleGetDockerMetrics(context.Background(), req)
191+
if err != nil {
192+
t.Fatalf("Handler returned error: %v", err)
193+
}
194+
textContent, ok := res.Content[0].(mcp.TextContent)
195+
if !ok {
196+
t.Fatalf("Result content not TextContent: %T", res.Content[0])
197+
}
198+
if res.IsError {
199+
// Docker not available, that's OK in CI
200+
return
201+
}
202+
var data map[string]interface{}
203+
if parseErr := json.Unmarshal([]byte(textContent.Text), &data); parseErr != nil {
204+
t.Fatalf("Failed to parse result JSON: %v", parseErr)
205+
}
206+
total, ok := data["total"].(float64)
207+
if !ok {
208+
t.Fatal("'total' is not a number")
209+
}
210+
if total != 0 {
211+
t.Errorf("Expected 0 containers for nonexistent filter, got %v", total)
212+
}
161213
}
162214

163215
func TestHandleGetNetworkConnections(t *testing.T) {

0 commit comments

Comments
 (0)