Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.12-alpine

WORKDIR /app

RUN apk add --no-cache docker-cli

RUN pip install --no-cache-dir uv

COPY pyproject.toml README.md ./
COPY src/ src/

RUN uv pip install --system --no-cache .

EXPOSE 8080

ENV DOCKER_MCP_TRANSPORT=http
ENV DOCKER_MCP_HOST=0.0.0.0
ENV DOCKER_MCP_PORT=8080

HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"

ENTRYPOINT ["docker-mcp"]
27 changes: 27 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
# docker-mcp — containerised HTTP deployment example
#
# Exposes the MCP server at http://localhost:4001/mcp
# Mount the Docker socket read-only so docker-mcp can manage containers
# without requiring a Portainer or remote Docker API.
#
# MCP client config (.mcp.json or claude_desktop_config.json):
# {
# "mcpServers": {
# "docker-mcp": { "type": "http", "url": "http://localhost:4001/mcp" }
# }
# }

services:
docker-mcp:
build: .
# or: image: ghcr.io/quantgeekdev/docker-mcp:latest (once published)
container_name: docker-mcp
restart: unless-stopped
ports:
- "127.0.0.1:4001:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
DOCKER_MCP_TRANSPORT: http
DOCKER_MCP_PORT: "8080"
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.28.0",
"mcp>=1.0.0",
"mcp>=1.6.0",
"python-dotenv>=1.0.1",
"python-on-whales>=0.67.0",
"pyyaml>=6.0.1"
"pyyaml>=6.0.1",
"starlette>=0.40.0",
"uvicorn>=0.27.0",
]

[[project.authors]]
Expand Down
70 changes: 70 additions & 0 deletions src/docker_mcp/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,73 @@ async def handle_list_containers(arguments: Dict[str, Any]) -> List[TextContent]
except Exception as e:
debug_output = "\n".join(debug_info)
return [TextContent(type="text", text=f"Error listing containers: {str(e)}\n\nDebug Information:\n{debug_output}")]

@staticmethod
async def handle_stop_container(arguments: Dict[str, Any]) -> List[TextContent]:
try:
container_name = arguments.get("container_name")
if not container_name:
raise ValueError("Missing required container_name")
await asyncio.to_thread(docker_client.container.stop, container_name)
return [TextContent(type="text", text=f"Container '{container_name}' stopped.")]
except Exception as e:
return [TextContent(type="text", text=f"Error stopping container: {str(e)}")]

@staticmethod
async def handle_start_container(arguments: Dict[str, Any]) -> List[TextContent]:
try:
container_name = arguments.get("container_name")
if not container_name:
raise ValueError("Missing required container_name")
await asyncio.to_thread(docker_client.container.start, container_name)
return [TextContent(type="text", text=f"Container '{container_name}' started.")]
except Exception as e:
return [TextContent(type="text", text=f"Error starting container: {str(e)}")]

@staticmethod
async def handle_restart_container(arguments: Dict[str, Any]) -> List[TextContent]:
try:
container_name = arguments.get("container_name")
if not container_name:
raise ValueError("Missing required container_name")
await asyncio.to_thread(docker_client.container.restart, container_name)
return [TextContent(type="text", text=f"Container '{container_name}' restarted.")]
except Exception as e:
return [TextContent(type="text", text=f"Error restarting container: {str(e)}")]

@staticmethod
async def handle_inspect_container(arguments: Dict[str, Any]) -> List[TextContent]:
try:
container_name = arguments.get("container_name")
if not container_name:
raise ValueError("Missing required container_name")
container = await asyncio.to_thread(docker_client.container.inspect, container_name)

info = [
f"Name: {container.name}",
f"ID: {container.id[:12]}",
f"Image: {container.config.image}",
f"Status: {container.state.status}",
]
try:
ports = container.network_settings.ports
if ports:
info.append(f"Ports: {ports}")
except Exception:
pass
try:
if container.mounts:
mounts = [f" {m.source} -> {m.destination} ({m.mode})" for m in container.mounts]
info.append("Mounts:\n" + "\n".join(mounts))
except Exception:
pass
try:
env = container.config.env
if env:
info.append("Env:\n" + "\n".join(f" {e}" for e in env))
except Exception:
pass

return [TextContent(type="text", text="\n".join(info))]
except Exception as e:
return [TextContent(type="text", text=f"Error inspecting container: {str(e)}")]
173 changes: 153 additions & 20 deletions src/docker_mcp/server.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import argparse
import asyncio
import os
import signal
import sys
from typing import List, Dict, Any
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Dict, List

import mcp.server.stdio
import mcp.types as types
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
import mcp.server.stdio

from .handlers import DockerHandlers

server = Server("docker-mcp")

# Manage-only gate (#113). When false (default), the container-creation surface —
# create-container, deploy-compose, and the deploy-stack prompt — is not advertised
# and is refused if called. This pairs with the scoped docker-socket-proxy, which also
# denies the underlying create/pull API calls (defense in depth). Set
# DOCKER_MCP_ALLOW_CREATE=true to restore the full lifecycle capability.
ALLOW_CREATE = os.environ.get("DOCKER_MCP_ALLOW_CREATE", "false").strip().lower() in ("1", "true", "yes")
_CREATE_TOOLS = {"create-container", "deploy-compose"}


@server.list_prompts()
async def handle_list_prompts() -> List[types.Prompt]:
# deploy-stack drives create-container/deploy-compose — hide it in manage-only mode (#113).
if not ALLOW_CREATE:
return []
return [
types.Prompt(
name="deploy-stack",
Expand Down Expand Up @@ -92,7 +108,8 @@ async def handle_get_prompt(name: str, arguments: Dict[str, str] | None) -> type

@server.list_tools()
async def handle_list_tools() -> List[types.Tool]:
return [
# Container-creation tools are only advertised when DOCKER_MCP_ALLOW_CREATE=true (#113).
create_tools = [
types.Tool(
name="create-container",
description="Create a new standalone Docker container",
Expand Down Expand Up @@ -125,6 +142,8 @@ async def handle_list_tools() -> List[types.Tool]:
"required": ["compose_yaml", "project_name"]
}
),
]
manage_tools = [
types.Tool(
name="get-logs",
description="Retrieve the latest logs for a specified Docker container",
Expand All @@ -143,15 +162,64 @@ async def handle_list_tools() -> List[types.Tool]:
"type": "object",
"properties": {}
}
)
),
types.Tool(
name="stop-container",
description="Stop a running Docker container",
inputSchema={
"type": "object",
"properties": {
"container_name": {"type": "string"}
},
"required": ["container_name"]
}
),
types.Tool(
name="start-container",
description="Start a stopped Docker container",
inputSchema={
"type": "object",
"properties": {
"container_name": {"type": "string"}
},
"required": ["container_name"]
}
),
types.Tool(
name="restart-container",
description="Restart a Docker container",
inputSchema={
"type": "object",
"properties": {
"container_name": {"type": "string"}
},
"required": ["container_name"]
}
),
types.Tool(
name="inspect-container",
description="Inspect a Docker container — returns status, image, ports, mounts, and environment",
inputSchema={
"type": "object",
"properties": {
"container_name": {"type": "string"}
},
"required": ["container_name"]
}
),
]
return (create_tools + manage_tools) if ALLOW_CREATE else manage_tools


@server.call_tool()
async def handle_call_tool(name: str, arguments: Dict[str, Any] | None) -> List[types.TextContent]:
if not arguments and name != "list-containers":
raise ValueError("Missing arguments")

# Defense in depth: refuse create-tools even if requested when not enabled (#113).
if name in _CREATE_TOOLS and not ALLOW_CREATE:
return [types.TextContent(type="text", text=f"Error: '{name}' is disabled (manage-only mode; DOCKER_MCP_ALLOW_CREATE=false)")]

try:
if name == "create-container":
return await DockerHandlers.handle_create_container(arguments)
Expand All @@ -161,29 +229,94 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any] | None) -> List[
return await DockerHandlers.handle_get_logs(arguments)
elif name == "list-containers":
return await DockerHandlers.handle_list_containers(arguments)
elif name == "stop-container":
return await DockerHandlers.handle_stop_container(arguments)
elif name == "start-container":
return await DockerHandlers.handle_start_container(arguments)
elif name == "restart-container":
return await DockerHandlers.handle_restart_container(arguments)
elif name == "inspect-container":
return await DockerHandlers.handle_inspect_container(arguments)
else:
raise ValueError(f"Unknown tool: {name}")
except Exception as e:
return [types.TextContent(type="text", text=f"Error: {str(e)} | Arguments: {arguments}")]


def _init_options() -> InitializationOptions:
return InitializationOptions(
server_name="docker-mcp",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)


def _create_starlette_app():
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route

session_manager = StreamableHTTPSessionManager(
app=server,
event_store=None,
json_response=False,
stateless=True,
)

@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
yield

async def health(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})

return Starlette(
lifespan=lifespan,
routes=[
Route("/health", endpoint=health),
Mount("/mcp", app=session_manager.handle_request),
],
)


async def main():
signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)

async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="docker-mcp",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
parser = argparse.ArgumentParser(description="docker-mcp: Docker MCP server")
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default=os.environ.get("DOCKER_MCP_TRANSPORT", "stdio"),
help="Transport mode: stdio (default) or http",
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("DOCKER_MCP_PORT", "8080")),
help="HTTP port (default: 8080, only used with --transport http)",
)
parser.add_argument(
"--host",
default=os.environ.get("DOCKER_MCP_HOST", "0.0.0.0"),
help="HTTP host (default: 0.0.0.0, only used with --transport http)",
)
args = parser.parse_args()

if args.transport == "http":
import uvicorn
app = _create_starlette_app()
config = uvicorn.Config(app, host=args.host, port=args.port, log_level="info")
srv = uvicorn.Server(config)
await srv.serve()
else:
signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, _init_options())


def handle_shutdown(signum, frame):
Expand Down
Loading