From 0942b220d075b5aa8db029a44245860f97253bf3 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 12 May 2026 01:26:33 +1000 Subject: [PATCH] feat: integrate MCP server via Streamable HTTP on existing web server (#4684) --- docs/.vitepress/config.ts | 1 + docs/guide/mcp.md | 113 +++++++++ docs/guide/supported-env-vars.md | 1 + go.mod | 5 + go.sum | 10 + internal/mcp/server.go | 377 +++++++++++++++++++++++++++++++ internal/mcp/server_test.go | 364 +++++++++++++++++++++++++++++ internal/support/cli/args.go | 1 + internal/web/routes.go | 8 + main.go | 1 + 10 files changed, 881 insertions(+) create mode 100644 docs/guide/mcp.md create mode 100644 internal/mcp/server.go create mode 100644 internal/mcp/server_test.go diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 069f02a5..831f759c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -82,6 +82,7 @@ export default defineConfig({ { text: "Authentication", link: "/guide/authentication" }, { text: "Actions", link: "/guide/actions" }, { text: "Shell Access", link: "/guide/shell" }, + { text: "MCP Integration", link: "/guide/mcp" }, { text: "Agent Mode", link: "/guide/agent" }, { text: "Reverse Proxy & Base Path", link: "/guide/changing-base" }, { text: "Container Names", link: "/guide/container-names" }, diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md new file mode 100644 index 00000000..d767c559 --- /dev/null +++ b/docs/guide/mcp.md @@ -0,0 +1,113 @@ +--- +title: MCP Integration +--- + +# MCP Integration + + + + +Dozzle supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) to allow AI coding assistants to interact with your Docker containers. When enabled, Dozzle exposes an MCP endpoint at `/api/mcp` using the Streamable HTTP transport, served from the same container — no extra processes or sidecars needed. + +This feature is **disabled** by default. To enable it, set the `--enable-mcp` flag or `DOZZLE_ENABLE_MCP` environment variable to `true`. + +::: code-group + +```sh [cli] +docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --enable-mcp +``` + +```yaml [docker-compose.yml] +services: + dozzle: + image: amir20/dozzle:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 8080:8080 + environment: + DOZZLE_ENABLE_MCP: true +``` + +::: + +## Available Tools + +All tools are **read-only** and do not modify containers. + +| Tool | Description | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `list_containers` | List all containers across all hosts. Supports optional `state` filter. | +| `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. | +| `list_hosts` | List all connected Docker hosts. | +| `get_container_stats` | Get CPU and memory usage history for a container. | + +## Configuring MCP Clients + +### VS Code (GitHub Copilot / Copilot Chat) + +Add the following to your `.vscode/mcp.json` or user MCP settings: + +```json +{ + "servers": { + "dozzle": { + "type": "http", + "url": "http://localhost:8080/api/mcp" + } + } +} +``` + +### Claude Desktop + +Add the following to your Claude Desktop MCP configuration: + +```json +{ + "mcpServers": { + "dozzle": { + "type": "streamable-http", + "url": "http://localhost:8080/api/mcp" + } + } +} +``` + +> [!NOTE] +> Replace `localhost:8080` with your Dozzle instance address. If Dozzle is configured with a custom base path (e.g., `--base /dozzle`), the MCP endpoint will be at `/dozzle/api/mcp`. + +## Authentication + +The MCP endpoint is part of the authenticated API group. When authentication is enabled, MCP clients must provide valid credentials. + +### Simple Auth + +With `--auth-provider simple`, MCP clients need to include a valid JWT token in the `Authorization` header. To obtain a token: + +1. Send a `POST` request to `/api/token` with your username and password. +2. Configure your MCP client to send the token as a Bearer header. + +For example, in VS Code MCP settings: + +```json +{ + "servers": { + "dozzle": { + "type": "http", + "url": "http://localhost:8080/api/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +### Forward Proxy Auth + +With `--auth-provider forward-proxy`, the reverse proxy in front of Dozzle handles authentication and injects the appropriate headers. MCP clients should connect through the same proxy, and authentication will be handled transparently. + +### No Auth + +With no authentication provider configured (default), the MCP endpoint is publicly accessible. No additional configuration is needed. diff --git a/docs/guide/supported-env-vars.md b/docs/guide/supported-env-vars.md index ee68f927..d9b51295 100644 --- a/docs/guide/supported-env-vars.md +++ b/docs/guide/supported-env-vars.md @@ -21,6 +21,7 @@ Configurations can be done with flags or environment variables. The table below | `--auth-logout-url` | `DOZZLE_AUTH_LOGOUT_URL` | `""` | | `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` | | `--enable-shell` | `DOZZLE_ENABLE_SHELL` | `false` | +| `--enable-mcp` | `DOZZLE_ENABLE_MCP` | `false` | | `--disable-avatars` | `DOZZLE_DISABLE_AVATARS` | `false` | | `--filter` | `DOZZLE_FILTER` | `""` | | `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` | diff --git a/go.mod b/go.mod index 66ea04e9..7f9bbb3b 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,8 @@ require ( k8s.io/metrics v0.36.0 ) +require github.com/modelcontextprotocol/go-sdk v1.6.0 + require ( dario.cat/mergo v1.0.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect @@ -97,6 +99,7 @@ require ( github.com/gohugoio/hugo v0.161.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/joho/godotenv v1.5.1 // indirect @@ -124,6 +127,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -134,6 +138,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect diff --git a/go.sum b/go.sum index 8a5b2c84..48af8ad0 100644 --- a/go.sum +++ b/go.sum @@ -207,6 +207,8 @@ github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn7 github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs= github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI= github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -218,6 +220,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -291,6 +295,8 @@ github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/w github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -350,6 +356,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -395,6 +403,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..186e2a68 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,377 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/amir20/dozzle/internal/container" + container_support "github.com/amir20/dozzle/internal/support/container" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog/log" +) + +// 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 *mcp.Server + 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 := mcp.NewServer(&mcp.Implementation{ + Name: "dozzle", + Version: version, + }, &mcp.ServerOptions{ + Instructions: "Dozzle MCP server provides tools to list Docker containers, read container logs, and view container stats.", + }) + + s.mcpServer = mcpServer + s.registerTools() + + return s +} + +// Handler returns an http.Handler for the MCP streamable HTTP transport. +func (s *Server) Handler() http.Handler { + return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return s.mcpServer + }, nil) +} + +// --- Tool Parameter Types --- + +type listContainersParams struct { + State *string `json:"state,omitempty" jsonschema:"Filter by container state (running, exited, created, paused, dead). Leave empty for all."` +} + +type getContainerLogsParams struct { + Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."` + ContainerID string `json:"container_id" jsonschema:"The container ID (or short ID) to get logs from. Use list_containers to find this."` + SinceMinutes *int `json:"since_minutes,omitempty" jsonschema:"Fetch logs from the last N minutes. Defaults to 5."` + Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to read: stdout, stderr, or all. Defaults to all."` +} + +type getContainerStatsParams struct { + Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."` + ContainerID string `json:"container_id" jsonschema:"The container ID to get stats for. Use list_containers to find this."` +} + +func (s *Server) registerTools() { + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "list_containers", + Description: "List all Docker containers across all hosts. Returns container ID, name, image, state, host, and other metadata.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, s.handleListContainers) + + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_container_logs", + Description: "Fetch processed logs from a Docker container. Returns structured log entries with detected log levels, JSON parsing, and multi-line grouping.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, s.handleGetContainerLogs) + + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "list_hosts", + Description: "List all Docker hosts connected to Dozzle.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, s.handleListHosts) + + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_container_stats", + Description: "Get CPU and memory usage stats for a Docker container. Returns the last ~5 minutes of stats history with CPU percentage, memory percentage, and memory usage in bytes.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, s.handleGetContainerStats) +} + +// --- Tool Handlers --- + +func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolRequest, params *listContainersParams) (*mcp.CallToolResult, any, error) { + containers, errs := s.hostService.ListAllContainers(s.labels) + for _, err := range errs { + if err != nil { + log.Warn().Err(err).Msg("partial failure listing containers from a host") + } + } + + 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"` + } + + stateFilter := "" + if params.State != nil { + stateFilter = *params.State + } + + 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 nil, nil, fmt.Errorf("failed to marshal containers: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} + +func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) { + if params.Host == "" || params.ContainerID == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "host and container_id are required"}}, + IsError: true, + }, nil, nil + } + + containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}}, + IsError: true, + }, nil, nil + } + + stream := "" + if params.Stream != nil { + stream = *params.Stream + } + var stdType container.StdType + switch stream { + case "", "all": + stdType = container.STDALL + case "stdout": + stdType = container.STDOUT + case "stderr": + stdType = container.STDERR + default: + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", stream)}}, + IsError: true, + }, nil, nil + } + + sinceMinutes := 5 + if params.SinceMinutes != nil && *params.SinceMinutes > 0 { + sinceMinutes = *params.SinceMinutes + } + + logCtx, cancel := context.WithCancel(ctx) + defer cancel() + + since := time.Now().Add(-time.Duration(sinceMinutes) * time.Minute) + events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("failed to read logs: %v", err)}}, + IsError: true, + }, nil, 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.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "(no logs in the specified time range)"}}, + }, nil, nil + } + + var sb strings.Builder + encoder := json.NewEncoder(&sb) + for _, entry := range entries { + if err := encoder.Encode(entry); err != nil { + return nil, nil, fmt.Errorf("failed to encode log entry: %w", err) + } + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: strings.TrimRight(sb.String(), "\n")}}, + }, nil, nil +} + +func (s *Server) handleListHosts(ctx context.Context, _ *mcp.CallToolRequest, _ *struct{}) (*mcp.CallToolResult, any, 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"` + } + + 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 nil, nil, fmt.Errorf("failed to marshal hosts: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} + +func (s *Server) handleGetContainerStats(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerStatsParams) (*mcp.CallToolResult, any, error) { + if params.Host == "" || params.ContainerID == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "host and container_id are required"}}, + IsError: true, + }, nil, nil + } + + containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}}, + IsError: true, + }, nil, 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"` + } + + 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 nil, nil, fmt.Errorf("failed to marshal stats: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 00000000..959ba06b --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,364 @@ +package mcp + +import ( + "context" + "fmt" + "io" + "testing" + "time" + + "github.com/amir20/dozzle/internal/container" + container_support "github.com/amir20/dozzle/internal/support/container" + "github.com/amir20/dozzle/internal/utils" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockHostService struct { + containers []container.Container + hosts []container.Host + findErr error + listErrs []error + logEvents []*container.LogEvent + logErr error +} + +type stubClientService struct { + container_support.ClientService + events []*container.LogEvent + err error +} + +func (s *stubClientService) LogsBetweenDates(ctx context.Context, _ container.Container, _ time.Time, _ time.Time, _ container.StdType) (<-chan *container.LogEvent, error) { + if s.err != nil { + return nil, s.err + } + ch := make(chan *container.LogEvent) + go func() { + defer close(ch) + for _, e := range s.events { + select { + case ch <- e: + case <-ctx.Done(): + return + } + } + }() + return ch, nil +} + +func (s *stubClientService) RawLogs(context.Context, container.Container, time.Time, time.Time, container.StdType) (io.ReadCloser, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockHostService) FindContainer(host string, id string, labels container.ContainerLabels) (*container_support.ContainerService, error) { + if m.findErr != nil { + return nil, m.findErr + } + for _, c := range m.containers { + if c.ID == id && c.Host == host { + stub := &stubClientService{events: m.logEvents, err: m.logErr} + return container_support.NewContainerService(stub, c), nil + } + } + return nil, assert.AnError +} + +func (m *mockHostService) ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error) { + return m.containers, m.listErrs +} + +func (m *mockHostService) Hosts() []container.Host { + return m.hosts +} + +func TestListContainers(t *testing.T) { + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Image: "nginx:latest", State: "running", Host: "local"}, + {ID: "def456", Name: "db", Image: "postgres:15", State: "exited", Host: "local"}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + // Test listing all containers + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "list_containers", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Len(t, result.Content, 1) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "abc123") + assert.Contains(t, text, "def456") + + // Test filtering by state + result, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "list_containers", + Arguments: map[string]any{"state": "running"}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text = result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "abc123") + assert.NotContains(t, text, "def456") +} + +func TestListContainersPartialFailure(t *testing.T) { + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Image: "nginx:latest", State: "running", Host: "host1"}, + }, + listErrs: []error{nil, fmt.Errorf("host2 unreachable")}, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "list_containers", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "abc123") +} + +func TestListHosts(t *testing.T) { + svc := &mockHostService{ + hosts: []container.Host{ + {ID: "local", Name: "localhost", NCPU: 4, MemTotal: 8000000000, DockerVersion: "24.0", Type: "local", Available: true}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "list_hosts", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "localhost") + assert.Contains(t, text, "24.0") +} + +func TestGetContainerStats(t *testing.T) { + stats := utils.RingBufferFrom(300, []container.ContainerStat{ + {ID: "abc123", CPUPercent: 25.5, MemoryPercent: 50.0, MemoryUsage: 1024000}, + {ID: "abc123", CPUPercent: 30.0, MemoryPercent: 55.0, MemoryUsage: 1100000}, + }) + + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Host: "local", Stats: stats, MemoryLimit: 2048000, CPULimit: 2.0}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_container_stats", + Arguments: map[string]any{"host": "local", "container_id": "abc123"}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "25.5") + assert.Contains(t, text, "2048000") +} + +func TestGetContainerStatsNotFound(t *testing.T) { + svc := &mockHostService{ + containers: []container.Container{}, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_container_stats", + Arguments: map[string]any{"host": "local", "container_id": "nonexistent"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) +} + +func TestGetContainerLogsRequiredParams(t *testing.T) { + svc := &mockHostService{} + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + // Missing required params + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_container_logs", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) +} + +func TestNewServerRegistersTools(t *testing.T) { + svc := &mockHostService{} + s := NewServer(svc, nil, "v1.0.0") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + require.NoError(t, err) + + toolNames := make([]string, len(tools.Tools)) + for i, tool := range tools.Tools { + toolNames[i] = tool.Name + } + + assert.Contains(t, toolNames, "list_containers") + assert.Contains(t, toolNames, "get_container_logs") + assert.Contains(t, toolNames, "list_hosts") + assert.Contains(t, toolNames, "get_container_stats") + assert.Len(t, tools.Tools, 4) +} + +func TestGetContainerLogs(t *testing.T) { + now := time.Now() + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Host: "local"}, + }, + logEvents: []*container.LogEvent{ + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "hello"}, + {Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "boom"}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_container_logs", + Arguments: map[string]any{"host": "local", "container_id": "abc123"}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "hello") + assert.Contains(t, text, "boom") +} + +func TestGetContainerLogsInvalidStream(t *testing.T) { + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Host: "local"}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_container_logs", + Arguments: map[string]any{"host": "local", "container_id": "abc123", "stream": "bogus"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "invalid stream") +} diff --git a/internal/support/cli/args.go b/internal/support/cli/args.go index 9d5157bd..978a610b 100644 --- a/internal/support/cli/args.go +++ b/internal/support/cli/args.go @@ -25,6 +25,7 @@ type Args struct { AuthLogoutUrl string `arg:"--auth-logout-url,env:DOZZLE_AUTH_LOGOUT_URL" help:"sets the Logout URL used with Forward Proxy."` EnableActions bool `arg:"--enable-actions,env:DOZZLE_ENABLE_ACTIONS" default:"false" help:"enables essential actions on containers from the web interface."` EnableShell bool `arg:"--enable-shell,env:DOZZLE_ENABLE_SHELL" default:"false" help:"enables shell access to containers from the web interface."` + EnableMCP bool `arg:"--enable-mcp,env:DOZZLE_ENABLE_MCP" default:"false" help:"enables the MCP (Model Context Protocol) endpoint for LLM integration."` DisableAvatars bool `arg:"--disable-avatars,env:DOZZLE_DISABLE_AVATARS" default:"false" help:"disables avatars for authenticated users."` FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."` Filter map[string][]string `arg:"-"` diff --git a/internal/web/routes.go b/internal/web/routes.go index 6df21173..6a731b6a 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -11,6 +11,7 @@ import ( "github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/cloud" "github.com/amir20/dozzle/internal/container" + dozzle_mcp "github.com/amir20/dozzle/internal/mcp" "github.com/amir20/dozzle/internal/notification" "github.com/amir20/dozzle/internal/notification/dispatcher" container_support "github.com/amir20/dozzle/internal/support/container" @@ -48,6 +49,7 @@ type Config struct { Authorization Authorization EnableActions bool EnableShell bool + EnableMCP bool DisableAvatars bool ReleaseCheckMode ReleaseCheckMode Labels container.ContainerLabels @@ -213,6 +215,12 @@ func createRouter(h *handler) *chi.Mux { r.Patch("/cloud/config", h.updateCloudConfig) r.Delete("/cloud/config", h.deleteCloudConfig) r.Post("/cloud/feedback", h.cloudFeedback) + + // MCP (Model Context Protocol) endpoint + if h.config.EnableMCP { + mcpServer := dozzle_mcp.NewServer(h.hostService, h.config.Labels, h.config.Version) + r.Mount("/mcp", mcpServer.Handler()) + } }) // Public API routes diff --git a/main.go b/main.go index 17caadb6..9311f0cf 100644 --- a/main.go +++ b/main.go @@ -279,6 +279,7 @@ func createServer(args cli.Args, hostService web.HostService, cloudHooks web.Clo }, EnableActions: args.EnableActions, EnableShell: args.EnableShell, + EnableMCP: args.EnableMCP, DisableAvatars: args.DisableAvatars, ReleaseCheckMode: releaseCheckMode, Labels: args.Filter,