Compare commits

...

6 Commits

Author SHA1 Message Date
Amir Raminfar 1bef3078f8 Merge branch 'master' into claude/implement-mcp-go-server-jebjr 2026-03-09 10:29:48 -07:00
Amir Raminfar 687e91dd65 Merge branch 'master' into claude/implement-mcp-go-server-jebjr 2026-03-03 07:18:46 -08:00
Claude b071cb8083 refactor: use processed logs instead of raw logs in MCP server
Switch from RawLogs to LogsBetweenDates so the MCP get_container_logs
tool returns Dozzle's processed log events with detected log levels,
JSON parsing, and multi-line grouping. Output is JSONL with structured
entries containing timestamp, level, stream, type, and parsed message.

https://claude.ai/code/session_01YRr9yDHxRJBKxBbjARpRJM
2026-03-01 03:56:04 +00:00
Claude 66beac25d9 feat: add get_container_stats tool to MCP server
Exposes the last ~5 minutes of container stats history (up to 300 data
points) including CPU percentage, memory percentage, and memory usage.

https://claude.ai/code/session_01YRr9yDHxRJBKxBbjARpRJM
2026-03-01 03:49:42 +00:00
Claude 794baafe62 feat: switch MCP server from stdio to Streamable HTTP transport
Uses StreamableHTTPServer for remote access. Listens on :8081 by default,
configurable via --mcp-addr flag or DOZZLE_MCP_ADDR env var.

https://claude.ai/code/session_01YRr9yDHxRJBKxBbjARpRJM
2026-03-01 03:44:18 +00:00
Claude 5c32475389 feat: add MCP server with stdio transport for LLM integration
Implements a Model Context Protocol (MCP) server as a new `dozzle mcp`
subcommand using the mark3labs/mcp-go library. Exposes four tools:
- list_containers: List all containers across hosts with optional state filter
- get_container_logs: Fetch recent logs from a container (configurable time range)
- container_action: Start/stop/restart containers
- list_hosts: List all connected Docker hosts

Uses stdio transport for seamless integration with Claude and other MCP clients.

https://claude.ai/code/session_01YRr9yDHxRJBKxBbjARpRJM
2026-03-01 03:40:05 +00:00
6 changed files with 449 additions and 1 deletions
+6
View File
@@ -45,6 +45,11 @@ require (
k8s.io/metrics v0.35.2
)
require (
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/air-verse/air v1.64.5 // indirect
@@ -89,6 +94,7 @@ require (
github.com/lestrrat-go/jwx/v3 v3.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mark3labs/mcp-go v0.44.1
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
+6
View File
@@ -191,6 +191,8 @@ github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTA
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -232,6 +234,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
github.com/mark3labs/mcp-go v0.44.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -357,6 +361,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+403
View File
@@ -0,0 +1,403 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// HostService is the subset of web.HostService needed by the MCP server.
type HostService interface {
FindContainer(host string, id string, labels container.ContainerLabels) (*container_support.ContainerService, error)
ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error)
Hosts() []container.Host
}
// Server wraps an MCP server that exposes Dozzle container operations as tools.
type Server struct {
mcpServer *server.MCPServer
hostService HostService
labels container.ContainerLabels
}
// NewServer creates a new MCP server with Dozzle tools registered.
func NewServer(hostService HostService, labels container.ContainerLabels, version string) *Server {
s := &Server{
hostService: hostService,
labels: labels,
}
mcpServer := server.NewMCPServer(
"dozzle",
version,
server.WithToolCapabilities(false),
server.WithInstructions("Dozzle MCP server provides tools to list Docker containers, read container logs, and perform container actions (start/stop/restart)."),
)
s.mcpServer = mcpServer
s.registerTools()
return s
}
// ServeHTTP starts the MCP server as a Streamable HTTP server on the given address.
func (s *Server) ServeHTTP(addr string) error {
httpServer := server.NewStreamableHTTPServer(s.mcpServer)
return httpServer.Start(addr)
}
func (s *Server) registerTools() {
s.mcpServer.AddTool(listContainersTool(), s.handleListContainers)
s.mcpServer.AddTool(getContainerLogsTool(), s.handleGetContainerLogs)
s.mcpServer.AddTool(containerActionTool(), s.handleContainerAction)
s.mcpServer.AddTool(listHostsTool(), s.handleListHosts)
s.mcpServer.AddTool(getContainerStatsTool(), s.handleGetContainerStats)
}
// --- Tool Definitions ---
func listContainersTool() mcp.Tool {
return mcp.NewTool("list_containers",
mcp.WithDescription("List all Docker containers across all hosts. Returns container ID, name, image, state, host, and other metadata."),
mcp.WithString("state",
mcp.Description("Filter by container state (running, exited, created, paused, dead). Leave empty for all."),
mcp.Enum("running", "exited", "created", "paused", "dead", ""),
),
mcp.WithReadOnlyHintAnnotation(true),
)
}
func getContainerLogsTool() mcp.Tool {
return mcp.NewTool("get_container_logs",
mcp.WithDescription("Fetch processed logs from a Docker container. Returns structured log entries with detected log levels, JSON parsing, and multi-line grouping. Each entry includes timestamp, level, stream (stdout/stderr), message type (single/complex/group), and the parsed message content."),
mcp.WithString("host",
mcp.Description("The host ID where the container is running. Use list_containers to find this."),
mcp.Required(),
),
mcp.WithString("container_id",
mcp.Description("The container ID (or short ID) to get logs from. Use list_containers to find this."),
mcp.Required(),
),
mcp.WithNumber("since_minutes",
mcp.Description("Fetch logs from the last N minutes. Defaults to 5."),
),
mcp.WithString("stream",
mcp.Description("Which output stream to read: stdout, stderr, or all."),
mcp.Enum("stdout", "stderr", "all"),
),
mcp.WithReadOnlyHintAnnotation(true),
)
}
func containerActionTool() mcp.Tool {
return mcp.NewTool("container_action",
mcp.WithDescription("Perform an action on a Docker container: start, stop, or restart."),
mcp.WithString("host",
mcp.Description("The host ID where the container is running."),
mcp.Required(),
),
mcp.WithString("container_id",
mcp.Description("The container ID to act on."),
mcp.Required(),
),
mcp.WithString("action",
mcp.Description("The action to perform."),
mcp.Required(),
mcp.Enum("start", "stop", "restart"),
),
mcp.WithDestructiveHintAnnotation(true),
)
}
func listHostsTool() mcp.Tool {
return mcp.NewTool("list_hosts",
mcp.WithDescription("List all Docker hosts connected to Dozzle."),
mcp.WithReadOnlyHintAnnotation(true),
)
}
func getContainerStatsTool() mcp.Tool {
return mcp.NewTool("get_container_stats",
mcp.WithDescription("Get CPU and memory usage stats for a Docker container. Returns the last ~5 minutes of stats history (up to 300 data points) with CPU percentage, memory percentage, and memory usage in bytes."),
mcp.WithString("host",
mcp.Description("The host ID where the container is running. Use list_containers to find this."),
mcp.Required(),
),
mcp.WithString("container_id",
mcp.Description("The container ID to get stats for. Use list_containers to find this."),
mcp.Required(),
),
mcp.WithReadOnlyHintAnnotation(true),
)
}
// --- Tool Handlers ---
func (s *Server) handleListContainers(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
stateFilter := mcp.ParseString(request, "state", "")
containers, errs := s.hostService.ListAllContainers(s.labels)
for _, err := range errs {
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("error listing containers: %v", err)), nil
}
}
type containerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Health string `json:"health,omitempty"`
Host string `json:"host"`
Created time.Time `json:"created"`
Labels map[string]string `json:"labels,omitempty"`
Group string `json:"group,omitempty"`
}
var results []containerInfo
for _, c := range containers {
if stateFilter != "" && c.State != stateFilter {
continue
}
results = append(results, containerInfo{
ID: c.ID,
Name: c.Name,
Image: c.Image,
State: c.State,
Health: c.Health,
Host: c.Host,
Created: c.Created,
Labels: c.Labels,
Group: c.Group,
})
}
data, err := json.Marshal(results)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal containers: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}
func (s *Server) handleGetContainerLogs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
host := mcp.ParseString(request, "host", "")
containerID := mcp.ParseString(request, "container_id", "")
sinceMinutes := mcp.ParseFloat64(request, "since_minutes", 5)
stream := mcp.ParseString(request, "stream", "all")
if host == "" || containerID == "" {
return mcp.NewToolResultError("host and container_id are required"), nil
}
containerSvc, err := s.hostService.FindContainer(host, containerID, s.labels)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("container not found: %v", err)), nil
}
var stdType container.StdType
switch stream {
case "stdout":
stdType = container.STDOUT
case "stderr":
stdType = container.STDERR
default:
stdType = container.STDALL
}
since := time.Now().Add(-time.Duration(sinceMinutes) * time.Minute)
events, err := containerSvc.LogsBetweenDates(ctx, since, time.Now(), stdType)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read logs: %v", err)), nil
}
type logEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level,omitempty"`
Stream string `json:"stream,omitempty"`
Type string `json:"type"`
Message any `json:"message"`
}
var entries []logEntry
totalSize := 0
const maxSize = 1024 * 1024 // 1MB limit
for event := range events {
var msg any
switch event.Type {
case container.LogTypeGroup:
if fragments, ok := event.Message.([]container.LogFragment); ok {
lines := make([]string, len(fragments))
for i, f := range fragments {
lines[i] = f.Message
}
msg = lines
} else {
msg = event.RawMessage
}
case container.LogTypeComplex:
msg = event.Message
default:
msg = event.RawMessage
}
entry := logEntry{
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
Level: event.Level,
Stream: event.Stream,
Type: string(event.Type),
Message: msg,
}
line, err := json.Marshal(entry)
if err != nil {
continue
}
totalSize += len(line) + 1
if totalSize > maxSize {
break
}
entries = append(entries, entry)
}
if len(entries) == 0 {
return mcp.NewToolResultText("(no logs in the specified time range)"), nil
}
var sb strings.Builder
encoder := json.NewEncoder(&sb)
for _, entry := range entries {
encoder.Encode(entry)
}
return mcp.NewToolResultText(strings.TrimRight(sb.String(), "\n")), nil
}
func (s *Server) handleContainerAction(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
host := mcp.ParseString(request, "host", "")
containerID := mcp.ParseString(request, "container_id", "")
action := mcp.ParseString(request, "action", "")
if host == "" || containerID == "" || action == "" {
return mcp.NewToolResultError("host, container_id, and action are required"), nil
}
containerAction, err := container.ParseContainerAction(action)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid action: %v", err)), nil
}
containerSvc, err := s.hostService.FindContainer(host, containerID, s.labels)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("container not found: %v", err)), nil
}
if err := containerSvc.Action(ctx, containerAction); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("action failed: %v", err)), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Successfully performed '%s' on container %s", action, containerSvc.Container.Name)), nil
}
func (s *Server) handleListHosts(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
hosts := s.hostService.Hosts()
type hostInfo struct {
ID string `json:"id"`
Name string `json:"name"`
NCPU int `json:"nCPU"`
MemTotal int64 `json:"memTotal"`
DockerVersion string `json:"dockerVersion"`
Type string `json:"type"`
Available bool `json:"available"`
}
var results []hostInfo
for _, h := range hosts {
results = append(results, hostInfo{
ID: h.ID,
Name: h.Name,
NCPU: h.NCPU,
MemTotal: h.MemTotal,
DockerVersion: h.DockerVersion,
Type: h.Type,
Available: h.Available,
})
}
data, err := json.Marshal(results)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal hosts: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}
func (s *Server) handleGetContainerStats(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
host := mcp.ParseString(request, "host", "")
containerID := mcp.ParseString(request, "container_id", "")
if host == "" || containerID == "" {
return mcp.NewToolResultError("host and container_id are required"), nil
}
containerSvc, err := s.hostService.FindContainer(host, containerID, s.labels)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("container not found: %v", err)), nil
}
c := containerSvc.Container
type statEntry struct {
CPUPercent float64 `json:"cpuPercent"`
MemoryPercent float64 `json:"memoryPercent"`
MemoryUsage float64 `json:"memoryUsageBytes"`
}
type statsResponse struct {
ContainerID string `json:"containerId"`
ContainerName string `json:"containerName"`
MemoryLimit uint64 `json:"memoryLimitBytes,omitempty"`
CPULimit float64 `json:"cpuLimit,omitempty"`
DataPoints int `json:"dataPoints"`
Stats []statEntry `json:"stats"`
}
var entries []statEntry
if c.Stats != nil {
for _, stat := range c.Stats.Data() {
entries = append(entries, statEntry{
CPUPercent: stat.CPUPercent,
MemoryPercent: stat.MemoryPercent,
MemoryUsage: stat.MemoryUsage,
})
}
}
resp := statsResponse{
ContainerID: c.ID,
ContainerName: c.Name,
MemoryLimit: c.MemoryLimit,
CPULimit: c.CPULimit,
DataPoints: len(entries),
Stats: entries,
}
data, err := json.Marshal(resp)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal stats: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}
+1
View File
@@ -42,6 +42,7 @@ type Args struct {
Generate *GenerateCmd `arg:"subcommand:generate" help:"generates a configuration file for simple auth"`
Agent *AgentCmd `arg:"subcommand:agent" help:"starts the agent"`
AgentTest *AgentTestCmd `arg:"subcommand:agent-test" help:"tests an agent"`
MCP *MCPCmd `arg:"subcommand:mcp" help:"starts the MCP server (stdio transport)"`
}
type Runnable interface {
+32
View File
@@ -0,0 +1,32 @@
package cli
import (
"embed"
"fmt"
dozzle_mcp "github.com/amir20/dozzle/internal/mcp"
"github.com/rs/zerolog/log"
)
type MCPCmd struct {
Addr string `arg:"--mcp-addr,env:DOZZLE_MCP_ADDR" default:":8081" help:"sets host:port to bind for the MCP HTTP server"`
}
func (m *MCPCmd) Run(args Args, embeddedCerts embed.FS) error {
if args.Mode != "server" {
return fmt.Errorf("mcp command is only available in server mode")
}
multiHostService := CreateMultiHostService(embeddedCerts, args)
if multiHostService.TotalClients() == 0 {
return fmt.Errorf("could not connect to any Docker Engine")
}
log.Info().Msgf("Dozzle MCP server version %s", args.Version())
log.Info().Int("clients", multiHostService.TotalClients()).Msg("Connected to Docker")
mcpServer := dozzle_mcp.NewServer(multiHostService, args.Filter, args.Version())
log.Info().Msgf("MCP HTTP server listening on %s", args.MCP.Addr)
return mcpServer.ServeHTTP(args.MCP.Addr)
}
+1 -1
View File
@@ -34,7 +34,7 @@ var certs embed.FS
//go:generate protoc --go_out=. --go-grpc_out=. --proto_path=./protos ./protos/rpc.proto ./protos/types.proto
func main() {
cli.ValidateEnvVars(cli.Args{}, cli.AgentCmd{})
cli.ValidateEnvVars(cli.Args{}, cli.AgentCmd{}, cli.MCPCmd{})
args, subcommand := cli.ParseArgs()
if subcommand != nil {
runnable, ok := subcommand.(cli.Runnable)