feat: integrate MCP server via Streamable HTTP on existing web server (#4684)

This commit is contained in:
Aaron Powell
2026-05-12 01:26:33 +10:00
committed by GitHub
parent e8bc72f27e
commit 0942b220d0
10 changed files with 881 additions and 0 deletions
+1
View File
@@ -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" },
+113
View File
@@ -0,0 +1,113 @@
---
title: MCP Integration
---
# MCP Integration
<Badge type="tip" text="Docker" />
<Badge type="tip" text="Swarm" />
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 <your-jwt-token>"
}
}
}
}
```
### 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.
+1
View File
@@ -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` |
+5
View File
@@ -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
+10
View File
@@ -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=
+377
View File
@@ -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
}
+364
View File
@@ -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")
}
+1
View File
@@ -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:"-"`
+8
View File
@@ -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
+1
View File
@@ -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,