diff --git a/README.md b/README.md index 3bd00be..55ba286 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ A powerful Model Context Protocol (MCP) server for Docker operations, enabling s - 📦 Docker Compose stack deployment - 🔍 Container logs retrieval - 📊 Container listing and status monitoring +- 🌐 **Remote Docker host support** (via `DOCKER_HOST` and `DOCKER_CONTEXT`) +- 🔎 **Detailed container inspection** (ports, volumes, environment variables) ### 🎬 Demos #### Deploying a Docker Compose Stack @@ -46,6 +48,40 @@ To try this in Claude Desktop app, add this to your claude config files: } ``` +### Remote Docker Host Support + +To connect to a remote Docker host, use environment variables: + +```json +{ + "mcpServers": { + "docker-mcp": { + "command": "uvx", + "args": ["docker-mcp"], + "env": { + "DOCKER_HOST": "ssh://user@remote-host" + } + } + } +} +``` + +Or use Docker contexts: + +```json +{ + "mcpServers": { + "docker-mcp": { + "command": "uvx", + "args": ["docker-mcp"], + "env": { + "DOCKER_CONTEXT": "remote-context" + } + } + } +} +``` + ### Installing via Smithery To install Docker MCP for Claude Desktop automatically via [Smithery](https://smithery.ai/protocol/docker-mcp): @@ -176,6 +212,23 @@ Lists all Docker containers {} ``` +### get-container-info +Get detailed information about a specific container including ports, volumes, environment variables, and resource limits +```json +{ + "container_name": "my-container" +} +``` + +Returns comprehensive container details including: +- Container metadata (ID, status, image, creation time) +- Environment variables +- Port mappings +- Volume mounts +- Network settings +- Resource limits +- Working directory and command + ## 🚧 Current Limitations - No built-in environment variable support for containers diff --git a/src/docker_mcp/handlers.py b/src/docker_mcp/handlers.py index be3ca66..735ea24 100644 --- a/src/docker_mcp/handlers.py +++ b/src/docker_mcp/handlers.py @@ -6,7 +6,20 @@ from python_on_whales import DockerClient from mcp.types import TextContent, Tool, Prompt, PromptArgument, GetPromptResult, PromptMessage from .docker_executor import DockerComposeExecutor -docker_client = DockerClient() + +def get_docker_client(): + """Get DockerClient with support for DOCKER_HOST and DOCKER_CONTEXT env vars.""" + docker_host = os.getenv('DOCKER_HOST') + docker_context = os.getenv('DOCKER_CONTEXT') + + if docker_host: + return DockerClient(host=docker_host) + elif docker_context: + return DockerClient(context_name=docker_context) + else: + return DockerClient() + +docker_client = get_docker_client() async def parse_port_mapping(host_key: str, container_port: str | int) -> tuple[str, str] | tuple[str, str, str]: @@ -192,3 +205,104 @@ 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_get_container_info(arguments: Dict[str, Any]) -> List[TextContent]: + debug_info = [] + try: + container_name = arguments.get("container_name") + if not container_name: + raise ValueError("Missing required container_name") + + debug_info.append(f"Getting detailed info for container '{container_name}'") + + # Get full container inspection data + container = await asyncio.to_thread(docker_client.container.inspect, container_name) + + # Format comprehensive container information + info_lines = [ + f"=== Container Information ===", + f"Name: {container.name}", + f"ID: {container.id}", + f"Status: {container.state.status}", + f"Image: {container.config.image}", + f"Created: {container.created}", + f"Started: {container.state.started_at if hasattr(container.state, 'started_at') else 'N/A'}", + "", + f"=== Environment Variables ===" + ] + + # Environment variables + if container.config.env: + for env_var in container.config.env: + info_lines.append(f" {env_var}") + else: + info_lines.append(" No environment variables set") + + info_lines.extend(["", f"=== Port Mappings ==="]) + + # Port mappings + if hasattr(container, 'network_settings') and container.network_settings.ports: + for container_port, host_configs in container.network_settings.ports.items(): + if host_configs: + for host_config in host_configs: + info_lines.append(f" {host_config.get('HostIp', '0.0.0.0')}:{host_config['HostPort']} -> {container_port}") + else: + info_lines.append(f" {container_port} (not bound to host)") + else: + info_lines.append(" No port mappings") + + info_lines.extend(["", f"=== Volume Mounts ==="]) + + # Volume mounts + if container.mounts: + for mount in container.mounts: + mount_type = getattr(mount, 'type', 'unknown') + source = getattr(mount, 'source', 'N/A') + destination = getattr(mount, 'destination', 'N/A') + mode = getattr(mount, 'mode', 'N/A') + info_lines.append(f" {mount_type}: {source} -> {destination} ({mode})") + else: + info_lines.append(" No volume mounts") + + info_lines.extend(["", f"=== Network Settings ==="]) + + # Network information + if hasattr(container, 'network_settings'): + networks = getattr(container.network_settings, 'networks', {}) + if networks: + for network_name, network_info in networks.items(): + ip_address = getattr(network_info, 'ip_address', 'N/A') + info_lines.append(f" Network: {network_name} (IP: {ip_address})") + else: + info_lines.append(" No network information available") + + info_lines.extend(["", f"=== Resource Limits ==="]) + + # Resource limits + if hasattr(container.host_config, 'memory') and container.host_config.memory: + memory_limit = container.host_config.memory / (1024*1024) # Convert to MB + info_lines.append(f" Memory Limit: {memory_limit:.0f}MB") + else: + info_lines.append(" Memory Limit: Not set") + + if hasattr(container.host_config, 'cpu_shares') and container.host_config.cpu_shares: + info_lines.append(f" CPU Shares: {container.host_config.cpu_shares}") + else: + info_lines.append(" CPU Shares: Not set") + + info_lines.extend(["", f"=== Working Directory & Command ==="]) + info_lines.append(f" Working Dir: {getattr(container.config, 'working_dir', 'N/A')}") + + if hasattr(container.config, 'cmd') and container.config.cmd: + cmd_str = ' '.join(container.config.cmd) if isinstance(container.config.cmd, list) else str(container.config.cmd) + info_lines.append(f" Command: {cmd_str}") + else: + info_lines.append(" Command: N/A") + + container_info = "\n".join(info_lines) + return [TextContent(type="text", text=f"{container_info}\n\nDebug Info:\n{chr(10).join(debug_info)}")] + + except Exception as e: + debug_output = "\n".join(debug_info) + return [TextContent(type="text", text=f"Error getting container info: {str(e)}\n\nDebug Information:\n{debug_output}")] diff --git a/src/docker_mcp/server.py b/src/docker_mcp/server.py index df49e70..8b07a58 100644 --- a/src/docker_mcp/server.py +++ b/src/docker_mcp/server.py @@ -143,6 +143,17 @@ async def handle_list_tools() -> List[types.Tool]: "type": "object", "properties": {} } + ), + types.Tool( + name="get-container-info", + description="Get detailed information about a specific container (ports, volumes, environment variables, etc.)", + inputSchema={ + "type": "object", + "properties": { + "container_name": {"type": "string"} + }, + "required": ["container_name"] + } ) ] @@ -161,6 +172,8 @@ 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 == "get-container-info": + return await DockerHandlers.handle_get_container_info(arguments) else: raise ValueError(f"Unknown tool: {name}") except Exception as e: