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,