mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-23 04:10:12 +00:00
feat: integrate MCP server via Streamable HTTP on existing web server (#4684)
This commit is contained in:
@@ -82,6 +82,7 @@ export default defineConfig({
|
|||||||
{ text: "Authentication", link: "/guide/authentication" },
|
{ text: "Authentication", link: "/guide/authentication" },
|
||||||
{ text: "Actions", link: "/guide/actions" },
|
{ text: "Actions", link: "/guide/actions" },
|
||||||
{ text: "Shell Access", link: "/guide/shell" },
|
{ text: "Shell Access", link: "/guide/shell" },
|
||||||
|
{ text: "MCP Integration", link: "/guide/mcp" },
|
||||||
{ text: "Agent Mode", link: "/guide/agent" },
|
{ text: "Agent Mode", link: "/guide/agent" },
|
||||||
{ text: "Reverse Proxy & Base Path", link: "/guide/changing-base" },
|
{ text: "Reverse Proxy & Base Path", link: "/guide/changing-base" },
|
||||||
{ text: "Container Names", link: "/guide/container-names" },
|
{ text: "Container Names", link: "/guide/container-names" },
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -21,6 +21,7 @@ Configurations can be done with flags or environment variables. The table below
|
|||||||
| `--auth-logout-url` | `DOZZLE_AUTH_LOGOUT_URL` | `""` |
|
| `--auth-logout-url` | `DOZZLE_AUTH_LOGOUT_URL` | `""` |
|
||||||
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
|
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
|
||||||
| `--enable-shell` | `DOZZLE_ENABLE_SHELL` | `false` |
|
| `--enable-shell` | `DOZZLE_ENABLE_SHELL` | `false` |
|
||||||
|
| `--enable-mcp` | `DOZZLE_ENABLE_MCP` | `false` |
|
||||||
| `--disable-avatars` | `DOZZLE_DISABLE_AVATARS` | `false` |
|
| `--disable-avatars` | `DOZZLE_DISABLE_AVATARS` | `false` |
|
||||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
|
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ require (
|
|||||||
k8s.io/metrics v0.36.0
|
k8s.io/metrics v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/modelcontextprotocol/go-sdk v1.6.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 // 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/gohugoio/hugo v0.161.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/gnostic-models v0.7.1 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // 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/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
@@ -134,6 +138,7 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/xhit/go-str2duration/v2 v2.1.0 // 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/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
|
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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
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/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 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
||||||
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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/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 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
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 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
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/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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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."`
|
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."`
|
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."`
|
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."`
|
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."`
|
FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."`
|
||||||
Filter map[string][]string `arg:"-"`
|
Filter map[string][]string `arg:"-"`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/amir20/dozzle/internal/auth"
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
"github.com/amir20/dozzle/internal/cloud"
|
"github.com/amir20/dozzle/internal/cloud"
|
||||||
"github.com/amir20/dozzle/internal/container"
|
"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"
|
||||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||||
@@ -48,6 +49,7 @@ type Config struct {
|
|||||||
Authorization Authorization
|
Authorization Authorization
|
||||||
EnableActions bool
|
EnableActions bool
|
||||||
EnableShell bool
|
EnableShell bool
|
||||||
|
EnableMCP bool
|
||||||
DisableAvatars bool
|
DisableAvatars bool
|
||||||
ReleaseCheckMode ReleaseCheckMode
|
ReleaseCheckMode ReleaseCheckMode
|
||||||
Labels container.ContainerLabels
|
Labels container.ContainerLabels
|
||||||
@@ -213,6 +215,12 @@ func createRouter(h *handler) *chi.Mux {
|
|||||||
r.Patch("/cloud/config", h.updateCloudConfig)
|
r.Patch("/cloud/config", h.updateCloudConfig)
|
||||||
r.Delete("/cloud/config", h.deleteCloudConfig)
|
r.Delete("/cloud/config", h.deleteCloudConfig)
|
||||||
r.Post("/cloud/feedback", h.cloudFeedback)
|
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
|
// Public API routes
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ func createServer(args cli.Args, hostService web.HostService, cloudHooks web.Clo
|
|||||||
},
|
},
|
||||||
EnableActions: args.EnableActions,
|
EnableActions: args.EnableActions,
|
||||||
EnableShell: args.EnableShell,
|
EnableShell: args.EnableShell,
|
||||||
|
EnableMCP: args.EnableMCP,
|
||||||
DisableAvatars: args.DisableAvatars,
|
DisableAvatars: args.DisableAvatars,
|
||||||
ReleaseCheckMode: releaseCheckMode,
|
ReleaseCheckMode: releaseCheckMode,
|
||||||
Labels: args.Filter,
|
Labels: args.Filter,
|
||||||
|
|||||||
Reference in New Issue
Block a user