Files
cubbi/cubbi/mcp.py
Mathieu Virbel b9cffe3008 feat: add local MCP server support
- Add LocalMCP model for stdio-based MCP servers
- Implement add_local_mcp() method in MCPManager
- Add 'mcp add-local' CLI command with args and env support
- Update cubbi_init.py MCPConfig with command, args, env fields
- Add local MCP support in interactive configure tool
- Update image plugins (opencode, goose, crush) to handle local MCPs
  - OpenCode: Maps to "local" type with command array
  - Goose: Maps to "stdio" type with command/args
  - Crush: Maps to "stdio" transport type

Local MCPs run as stdio-based commands inside containers, allowing
users to integrate local MCP servers without containerization.
2025-09-25 16:12:24 -06:00

947 lines
33 KiB
Python

"""
MCP (Model Control Protocol) server management for Cubbi Container.
"""
import logging
import os
import tempfile
from typing import Any, Dict, List, Optional
import docker
from docker.errors import DockerException, ImageNotFound, NotFound
from .models import DockerMCP, LocalMCP, MCPContainer, MCPStatus, ProxyMCP, RemoteMCP
from .user_config import UserConfigManager
# Configure logging
logger = logging.getLogger(__name__)
class MCPManager:
"""Manager for MCP (Model Control Protocol) servers."""
def __init__(
self,
config_manager: Optional[UserConfigManager] = None,
):
"""Initialize the MCP manager."""
self.config_manager = config_manager or UserConfigManager()
try:
self.client = docker.from_env()
# Test connection
self.client.ping()
except DockerException as e:
logger.error(f"Error connecting to Docker: {e}")
self.client = None
def _ensure_mcp_network(self) -> str:
"""Ensure the MCP network exists and return its name.
Note: This is used only by the inspector, not for session-to-MCP connections.
"""
network_name = "cubbi-mcp-network"
if self.client:
networks = self.client.networks.list(names=[network_name])
if not networks:
self.client.networks.create(network_name, driver="bridge")
return network_name
def _get_mcp_dedicated_network(self, mcp_name: str) -> str:
"""Get or create a dedicated network for direct session-to-MCP connections.
Args:
mcp_name: The name of the MCP server
Returns:
The name of the dedicated network
"""
network_name = f"cubbi-mcp-{mcp_name}-network"
if self.client:
networks = self.client.networks.list(names=[network_name])
if not networks:
self.client.networks.create(network_name, driver="bridge")
return network_name
def list_mcps(self) -> List[Dict[str, Any]]:
"""List all configured MCP servers."""
mcps = self.config_manager.get("mcps", [])
return mcps
def get_mcp(self, name: str) -> Optional[Dict[str, Any]]:
"""Get an MCP configuration by name."""
mcps = self.list_mcps()
for mcp in mcps:
if mcp.get("name") == name:
return mcp
return None
def add_remote_mcp(
self,
name: str,
url: str,
headers: Dict[str, str] = None,
mcp_type: Optional[str] = None,
add_as_default: bool = True,
) -> Dict[str, Any]:
"""Add a remote MCP server.
Args:
name: Name of the MCP server
url: URL of the remote MCP server
headers: HTTP headers to use when connecting
add_as_default: Whether to add this MCP to the default MCPs list
Returns:
The MCP configuration dictionary
"""
# Create the remote MCP configuration
remote_mcp = RemoteMCP(
name=name,
url=url,
headers=headers or {},
mcp_type=mcp_type,
)
# Add to the configuration
mcps = self.list_mcps()
# Remove existing MCP with the same name if it exists
mcps = [mcp for mcp in mcps if mcp.get("name") != name]
# Add the new MCP
mcp_config = remote_mcp.model_dump()
mcps.append(mcp_config)
# Save the configuration
self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested
if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", [])
if name not in default_mcps:
default_mcps.append(name)
self.config_manager.set("defaults.mcps", default_mcps)
return mcp_config
def add_docker_mcp(
self,
name: str,
image: str,
command: str,
env: Dict[str, str] = None,
add_as_default: bool = True,
) -> Dict[str, Any]:
"""Add a Docker-based MCP server.
Args:
name: Name of the MCP server
image: Docker image for the MCP server
command: Command to run in the container
env: Environment variables to set in the container
add_as_default: Whether to add this MCP to the default MCPs list
Returns:
The MCP configuration dictionary
"""
# Create the Docker MCP configuration
docker_mcp = DockerMCP(
name=name,
image=image,
command=command,
env=env or {},
)
# Add to the configuration
mcps = self.list_mcps()
# Remove existing MCP with the same name if it exists
mcps = [mcp for mcp in mcps if mcp.get("name") != name]
# Add the new MCP
mcp_config = docker_mcp.model_dump()
mcps.append(mcp_config)
# Save the configuration
self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested
if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", [])
if name not in default_mcps:
default_mcps.append(name)
self.config_manager.set("defaults.mcps", default_mcps)
return mcp_config
def add_proxy_mcp(
self,
name: str,
base_image: str,
proxy_image: str,
command: str,
proxy_options: Dict[str, Any] = None,
env: Dict[str, str] = None,
host_port: Optional[int] = None,
add_as_default: bool = True,
) -> Dict[str, Any]:
"""Add a proxy-based MCP server.
Args:
name: Name of the MCP server
base_image: Base Docker image running the actual MCP server
proxy_image: Docker image for the MCP proxy
command: Command to run in the container
proxy_options: Options for the MCP proxy
env: Environment variables to set in the container
host_port: Host port to bind the MCP server to (auto-assigned if not specified)
add_as_default: Whether to add this MCP to the default MCPs list
Returns:
The MCP configuration dictionary
"""
# If no host port specified, find the next available port starting from 5101
if host_port is None:
# Get current MCPs and find highest assigned port
mcps = self.list_mcps()
highest_port = 5100 # Start at 5100, so next will be 5101
for mcp in mcps:
if mcp.get("type") == "proxy" and mcp.get("host_port"):
try:
port = int(mcp.get("host_port"))
if port > highest_port:
highest_port = port
except (ValueError, TypeError):
pass
# Next port will be highest + 1
host_port = highest_port + 1
# Create the Proxy MCP configuration
proxy_mcp = ProxyMCP(
name=name,
base_image=base_image,
proxy_image=proxy_image,
command=command,
proxy_options=proxy_options or {},
env=env or {},
host_port=host_port,
)
# Add to the configuration
mcps = self.list_mcps()
# Remove existing MCP with the same name if it exists
mcps = [mcp for mcp in mcps if mcp.get("name") != name]
# Add the new MCP
mcp_config = proxy_mcp.model_dump()
mcps.append(mcp_config)
# Save the configuration
self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested
if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", [])
if name not in default_mcps:
default_mcps.append(name)
self.config_manager.set("defaults.mcps", default_mcps)
return mcp_config
def add_local_mcp(
self,
name: str,
command: str,
args: List[str] = None,
env: Dict[str, str] = None,
add_as_default: bool = True,
) -> Dict[str, Any]:
"""Add a local MCP server.
Args:
name: Name of the MCP server
command: Path to executable
args: Command arguments
env: Environment variables to set for the command
add_as_default: Whether to add this MCP to the default MCPs list
Returns:
The MCP configuration dictionary
"""
# Create the Local MCP configuration
local_mcp = LocalMCP(
name=name,
command=command,
args=args or [],
env=env or {},
)
# Add to the configuration
mcps = self.list_mcps()
# Remove existing MCP with the same name if it exists
mcps = [mcp for mcp in mcps if mcp.get("name") != name]
# Add the new MCP
mcp_config = local_mcp.model_dump()
mcps.append(mcp_config)
# Save the configuration
self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested
if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", [])
if name not in default_mcps:
default_mcps.append(name)
self.config_manager.set("defaults.mcps", default_mcps)
return mcp_config
def remove_mcp(self, name: str) -> bool:
"""Remove an MCP server configuration.
Args:
name: Name of the MCP server to remove
Returns:
True if the MCP was successfully removed, False otherwise
"""
mcps = self.list_mcps()
# Filter out the MCP with the specified name
updated_mcps = [mcp for mcp in mcps if mcp.get("name") != name]
# If the length hasn't changed, the MCP wasn't found
if len(mcps) == len(updated_mcps):
return False
# Save the updated configuration
self.config_manager.set("mcps", updated_mcps)
# Also remove from default MCPs if it's there
default_mcps = self.config_manager.get("defaults.mcps", [])
if name in default_mcps:
default_mcps.remove(name)
self.config_manager.set("defaults.mcps", default_mcps)
# Stop and remove the container if it exists
self.stop_mcp(name)
return True
def get_mcp_container_name(self, mcp_name: str) -> str:
"""Get the Docker container name for an MCP server."""
return f"cubbi_mcp_{mcp_name}"
def start_mcp(self, name: str) -> Dict[str, Any]:
"""Start an MCP server container."""
if not self.client:
raise Exception("Docker client is not available")
# Get the MCP configuration
mcp_config = self.get_mcp(name)
if not mcp_config:
raise ValueError(f"MCP server '{name}' not found")
# Get the container name
container_name = self.get_mcp_container_name(name)
# Check if the container already exists
try:
container = self.client.containers.get(container_name)
# Check if we need to recreate the container due to port binding changes
needs_recreate = False
if mcp_config.get("type") == "proxy" and mcp_config.get("host_port"):
# Get the current container port bindings
port_bindings = container.attrs.get("HostConfig", {}).get(
"PortBindings", {}
)
sse_port = f"{mcp_config['proxy_options'].get('sse_port', 8080)}/tcp"
# Check if the port binding matches the configured host port
current_binding = port_bindings.get(sse_port, [])
if not current_binding or int(
current_binding[0].get("HostPort", 0)
) != mcp_config.get("host_port"):
logger.info(
f"Port binding changed for MCP '{name}', recreating container"
)
needs_recreate = True
# If we don't need to recreate, just start it if it's not running
if not needs_recreate:
if container.status != "running":
container.start()
# Return the container status
return {
"container_id": container.id,
"status": "running",
"name": name,
}
else:
# We need to recreate the container with new port bindings
logger.info(
f"Recreating container for MCP '{name}' with updated port bindings"
)
container.remove(force=True)
# Container doesn't exist, we need to create it
pass
except NotFound:
# Container doesn't exist, we need to create it
pass
# Ensure the MCP network exists
network_name = self._ensure_mcp_network()
# Handle different MCP types
mcp_type = mcp_config.get("type")
if mcp_type == "remote":
# Remote MCP servers don't need containers
return {
"status": "not_applicable",
"name": name,
"type": "remote",
}
elif mcp_type == "local":
# Local MCP servers don't need containers
return {
"status": "not_applicable",
"name": name,
"type": "local",
}
elif mcp_type == "docker":
# Pull the image if needed
try:
self.client.images.get(mcp_config["image"])
except ImageNotFound:
logger.info(f"Pulling image {mcp_config['image']}")
self.client.images.pull(mcp_config["image"])
# Create and start the container
container = self.client.containers.run(
image=mcp_config["image"],
command=mcp_config.get("command"),
name=container_name,
detach=True,
network=None, # Start without network, we'll add it with aliases
environment=mcp_config.get("env", {}),
labels={
"cubbi.mcp": "true",
"cubbi.mcp.name": name,
"cubbi.mcp.type": "docker",
},
)
# Connect to the inspector network
network = self.client.networks.get(network_name)
network.connect(container, aliases=[name])
logger.info(
f"Connected MCP server '{name}' to inspector network {network_name} with alias '{name}'"
)
# Create and connect to a dedicated network for session connections
dedicated_network_name = self._get_mcp_dedicated_network(name)
try:
dedicated_network = self.client.networks.get(dedicated_network_name)
except DockerException:
dedicated_network = self.client.networks.create(
dedicated_network_name, driver="bridge"
)
dedicated_network.connect(container, aliases=[name])
logger.info(
f"Connected MCP server '{name}' to dedicated network {dedicated_network_name} with alias '{name}'"
)
return {
"container_id": container.id,
"status": "running",
"name": name,
}
elif mcp_type == "proxy":
# For proxy, we need to create a custom Dockerfile and build an image
with tempfile.TemporaryDirectory() as tmp_dir:
# Create entrypoint script for mcp-proxy that runs the base MCP image
entrypoint_script = """#!/bin/sh
set -x
echo "Starting MCP proxy with base image $MCP_BASE_IMAGE (command: $MCP_COMMAND) on port $SSE_PORT"
# Verify if Docker socket is available
if [ ! -S /var/run/docker.sock ]; then
echo "ERROR: Docker socket not available. Cannot run base MCP image."
echo "Make sure the Docker socket is mounted from the host."
# Create a minimal fallback server for testing
cat > /tmp/fallback_server.py << 'EOF'
import json, sys, time
print(json.dumps({"type": "ready", "message": "Fallback server - Docker socket not available"}))
sys.stdout.flush()
while True:
line = sys.stdin.readline().strip()
if line:
try:
data = json.loads(line)
if data.get("type") == "ping":
print(json.dumps({"type": "pong", "id": data.get("id")}))
else:
print(json.dumps({"type": "error", "message": "Docker socket not available"}))
except:
print(json.dumps({"type": "error"}))
sys.stdout.flush()
time.sleep(1)
EOF
exec mcp-proxy \
--sse-port "$SSE_PORT" \
--sse-host "$SSE_HOST" \
--allow-origin "$ALLOW_ORIGIN" \
--pass-environment \
-- \
python /tmp/fallback_server.py
exit 1
fi
# Pull the base MCP image
echo "Pulling base MCP image: $MCP_BASE_IMAGE"
docker pull "$MCP_BASE_IMAGE" || true
# Prepare the command to run the MCP server
if [ -n "$MCP_COMMAND" ]; then
CMD="$MCP_COMMAND"
else
# Default to empty if no command specified
CMD=""
fi
echo "Running MCP server from image $MCP_BASE_IMAGE with command: $CMD"
# Run the actual MCP server in the base image and pipe its I/O to mcp-proxy
# Using docker run without -d to keep stdio connected
# Build env vars string to pass through to the inner container
ENV_ARGS=""
# Check if the environment variable names file exists
if [ -f "/mcp-envs.txt" ]; then
# Read env var names from file and pass them to docker
while read -r var_name; do
# Skip empty lines
if [ -n "$var_name" ]; then
# Simply add the env var - Docker will only pass it if it exists
ENV_ARGS="$ENV_ARGS -e $var_name"
fi
done < "/mcp-envs.txt"
echo "Passing environment variables from mcp-envs.txt: $ENV_ARGS"
fi
exec mcp-proxy \
--sse-port "$SSE_PORT" \
--sse-host "$SSE_HOST" \
--allow-origin "$ALLOW_ORIGIN" \
--pass-environment \
-- \
docker run --rm -i $ENV_ARGS "$MCP_BASE_IMAGE" $CMD
"""
# Write the entrypoint script
entrypoint_path = os.path.join(tmp_dir, "entrypoint.sh")
with open(entrypoint_path, "w") as f:
f.write(entrypoint_script)
# Create a file with environment variable names (no values)
env_names_path = os.path.join(tmp_dir, "mcp-envs.txt")
with open(env_names_path, "w") as f:
# Write one env var name per line
for env_name in mcp_config.get("env", {}).keys():
f.write(f"{env_name}\n")
# Create a Dockerfile for the proxy
dockerfile_content = f"""
FROM {mcp_config["proxy_image"]}
# Install Docker CLI (trying multiple package managers to handle different base images)
USER root
RUN (apt-get update && apt-get install -y docker.io) || \\
(apt-get update && apt-get install -y docker-ce-cli) || \\
(apk add --no-cache docker-cli) || \\
(yum install -y docker) || \\
echo "WARNING: Could not install Docker CLI - will fall back to minimal MCP server"
# Set environment variables for the proxy
ENV MCP_BASE_IMAGE={mcp_config["base_image"]}
ENV MCP_COMMAND="{mcp_config.get("command", "")}"
ENV SSE_PORT={mcp_config["proxy_options"].get("sse_port", 8080)}
ENV SSE_HOST={mcp_config["proxy_options"].get("sse_host", "0.0.0.0")}
ENV ALLOW_ORIGIN={mcp_config["proxy_options"].get("allow_origin", "*")}
ENV DEBUG=1
# Add environment variables from the configuration
{chr(10).join([f'ENV {k}="{v}"' for k, v in mcp_config.get("env", {}).items()])}
# Add env names file and entrypoint script
COPY mcp-envs.txt /mcp-envs.txt
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
"""
# Write the Dockerfile
dockerfile_path = os.path.join(tmp_dir, "Dockerfile")
with open(dockerfile_path, "w") as f:
f.write(dockerfile_content)
# Build the image
custom_image_name = f"cubbi_mcp_proxy_{name}"
logger.info(f"Building custom proxy image: {custom_image_name}")
self.client.images.build(
path=tmp_dir,
tag=custom_image_name,
rm=True,
)
# Format command for the Docker entrypoint arguments
# The MCP proxy container will handle this internally based on
# the MCP_BASE_IMAGE and MCP_COMMAND env vars we set
logger.info(
f"Starting MCP proxy with base_image={mcp_config['base_image']}, command={mcp_config.get('command', '')}"
)
# Get the SSE port from the proxy options
sse_port = mcp_config["proxy_options"].get("sse_port", 8080)
# Check if we need to bind to a host port
port_bindings = {}
if mcp_config.get("host_port"):
host_port = mcp_config.get("host_port")
port_bindings = {f"{sse_port}/tcp": host_port}
# Create and start the container
container = self.client.containers.run(
image=custom_image_name,
name=container_name,
detach=True,
network=None, # Start without network, we'll add it with aliases
volumes={
"/var/run/docker.sock": {
"bind": "/var/run/docker.sock",
"mode": "rw",
}
},
labels={
"cubbi.mcp": "true",
"cubbi.mcp.name": name,
"cubbi.mcp.type": "proxy",
},
ports=port_bindings, # Bind the SSE port to the host if configured
)
# Connect to the inspector network
network = self.client.networks.get(network_name)
network.connect(container, aliases=[name])
logger.info(
f"Connected MCP server '{name}' to inspector network {network_name} with alias '{name}'"
)
# Create and connect to a dedicated network for session connections
dedicated_network_name = self._get_mcp_dedicated_network(name)
try:
dedicated_network = self.client.networks.get(dedicated_network_name)
except DockerException:
dedicated_network = self.client.networks.create(
dedicated_network_name, driver="bridge"
)
dedicated_network.connect(container, aliases=[name])
logger.info(
f"Connected MCP server '{name}' to dedicated network {dedicated_network_name} with alias '{name}'"
)
return {
"container_id": container.id,
"status": "running",
"name": name,
}
else:
raise ValueError(f"Unsupported MCP type: {mcp_type}")
def stop_mcp(self, name: str) -> bool:
"""Stop an MCP server container.
Args:
name: The name of the MCP server to stop
Returns:
True if the operation was successful (including cases where the container doesn't exist)
"""
if not self.client:
logger.warning("Docker client is not available")
return False
# Get the MCP configuration - don't raise an exception if not found
mcp_config = self.get_mcp(name)
if not mcp_config:
logger.warning(
f"MCP server '{name}' not found, but continuing with removal"
)
return True
# Remote and Local MCPs don't have containers to stop
if mcp_config.get("type") in ["remote", "local"]:
return True
# Get the container name
container_name = self.get_mcp_container_name(name)
# Try to get, stop, and remove the container
try:
container = self.client.containers.get(container_name)
# Stop the container if it's running
if container.status == "running":
logger.info(f"Stopping MCP container '{name}'...")
container.stop(timeout=10)
# Remove the container regardless of its status
logger.info(f"Removing MCP container '{name}'...")
container.remove(force=True)
return True
except NotFound:
# Container doesn't exist - this is fine when removing
logger.info(f"MCP container '{name}' not found, nothing to stop or remove")
return True
except Exception as e:
# Log the error but don't fail the removal operation
logger.error(f"Error stopping/removing MCP container: {e}")
return True # Return true anyway to continue with removal
def restart_mcp(self, name: str) -> Dict[str, Any]:
"""Restart an MCP server container."""
if not self.client:
raise Exception("Docker client is not available")
# Get the MCP configuration
mcp_config = self.get_mcp(name)
if not mcp_config:
raise ValueError(f"MCP server '{name}' not found")
# Remote and Local MCPs don't have containers to restart
if mcp_config.get("type") in ["remote", "local"]:
return {
"status": "not_applicable",
"name": name,
"type": mcp_config.get("type"),
}
# Get the container name
container_name = self.get_mcp_container_name(name)
# Try to get and restart the container
try:
container = self.client.containers.get(container_name)
container.restart(timeout=10)
return {
"container_id": container.id,
"status": "running",
"name": name,
}
except NotFound:
# Container doesn't exist, start it
return self.start_mcp(name)
except Exception as e:
logger.error(f"Error restarting MCP container: {e}")
raise
def get_mcp_status(self, name: str) -> Dict[str, Any]:
"""Get the status of an MCP server."""
if not self.client:
raise Exception("Docker client is not available")
# Get the MCP configuration
mcp_config = self.get_mcp(name)
if not mcp_config:
raise ValueError(f"MCP server '{name}' not found")
# Remote MCPs don't have containers
if mcp_config.get("type") == "remote":
return {
"status": "not_applicable",
"name": name,
"type": "remote",
"url": mcp_config.get("url"),
}
# Local MCPs don't have containers
if mcp_config.get("type") == "local":
return {
"status": "not_applicable",
"name": name,
"type": "local",
"command": mcp_config.get("command"),
"args": mcp_config.get("args", []),
}
# Get the container name
container_name = self.get_mcp_container_name(name)
# Try to get the container status
try:
container = self.client.containers.get(container_name)
status = (
MCPStatus.RUNNING
if container.status == "running"
else MCPStatus.STOPPED
)
# Get container details
container_info = container.attrs
# Extract exposed ports from config
ports = {}
if (
"Config" in container_info
and "ExposedPorts" in container_info["Config"]
):
# Add all exposed ports
for port in container_info["Config"]["ExposedPorts"].keys():
ports[port] = None
# Add any ports that might be published
if (
"NetworkSettings" in container_info
and "Ports" in container_info["NetworkSettings"]
):
for port, mappings in container_info["NetworkSettings"][
"Ports"
].items():
if mappings:
# Port is bound to host
ports[port] = int(mappings[0]["HostPort"])
return {
"status": status.value,
"container_id": container.id,
"name": name,
"type": mcp_config.get("type"),
"image": container_info["Config"]["Image"],
"ports": ports,
"created": container_info["Created"],
}
except NotFound:
# Container doesn't exist
return {
"status": MCPStatus.NOT_FOUND.value,
"name": name,
"type": mcp_config.get("type"),
}
except Exception as e:
logger.error(f"Error getting MCP container status: {e}")
return {
"status": MCPStatus.FAILED.value,
"name": name,
"error": str(e),
}
def get_mcp_logs(self, name: str, tail: int = 100) -> str:
"""Get logs from an MCP server container."""
if not self.client:
raise Exception("Docker client is not available")
# Get the MCP configuration
mcp_config = self.get_mcp(name)
if not mcp_config:
raise ValueError(f"MCP server '{name}' not found")
# Remote and Local MCPs don't have logs
if mcp_config.get("type") == "remote":
return "Remote MCPs don't have local logs"
if mcp_config.get("type") == "local":
return "Local MCPs don't have container logs"
# Get the container name
container_name = self.get_mcp_container_name(name)
# Try to get the container logs
try:
container = self.client.containers.get(container_name)
logs = container.logs(tail=tail, timestamps=True).decode("utf-8")
return logs
except NotFound:
# Container doesn't exist
return f"MCP container '{name}' not found"
except Exception as e:
logger.error(f"Error getting MCP container logs: {e}")
return f"Error getting logs: {str(e)}"
def list_mcp_containers(self) -> List[MCPContainer]:
"""List all MCP containers."""
if not self.client:
raise Exception("Docker client is not available")
# Get all containers with the cubbi.mcp label
containers = self.client.containers.list(
all=True, filters={"label": "cubbi.mcp"}
)
result = []
for container in containers:
# Get container details
container_info = container.attrs
# Extract labels
labels = container_info["Config"]["Labels"]
# Extract exposed ports from config
ports = {}
if (
"Config" in container_info
and "ExposedPorts" in container_info["Config"]
):
# Add all exposed ports
for port in container_info["Config"]["ExposedPorts"].keys():
ports[port] = None
# Add any ports that might be published
if (
"NetworkSettings" in container_info
and "Ports" in container_info["NetworkSettings"]
):
for port, mappings in container_info["NetworkSettings"][
"Ports"
].items():
if mappings:
# Port is bound to host
ports[port] = int(mappings[0]["HostPort"])
# Determine status
status = (
MCPStatus.RUNNING
if container.status == "running"
else MCPStatus.STOPPED
)
# Create MCPContainer object
mcp_container = MCPContainer(
name=labels.get("cubbi.mcp.name", "unknown"),
container_id=container.id,
status=status,
image=container_info["Config"]["Image"],
ports=ports,
created_at=container_info["Created"],
type=labels.get("cubbi.mcp.type", "unknown"),
)
result.append(mcp_container)
return result