mirror of
https://github.com/Monadical-SAS/cubbi.git
synced 2025-12-20 12:19:07 +00:00
- 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.
947 lines
33 KiB
Python
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
|