feat(mcp): ensure inner mcp environemnt variables are passed

This commit is contained in:
2025-03-26 16:44:35 +01:00
parent 7805aa720e
commit 0d75bfc3d8
6 changed files with 269 additions and 155 deletions

View File

@@ -5,6 +5,14 @@ containers that run AI tools and development environments. It works with both
local Docker and a dedicated remote web service that manages containers in a local Docker and a dedicated remote web service that manages containers in a
Docker-in-Docker (DinD) environment. MC also supports connecting to MCP (Model Control Protocol) servers to extend AI tools with additional capabilities. Docker-in-Docker (DinD) environment. MC also supports connecting to MCP (Model Control Protocol) servers to extend AI tools with additional capabilities.
## Quick Reference
- `mc session create` - Create a new session
- `mcx` - Shortcut for `mc session create` (mount directories or clone repos)
- `mcx .` - Mount the current directory
- `mcx /path/to/dir` - Mount a specific directory
- `mcx https://github.com/user/repo` - Clone a repository
## Requirements ## Requirements
- [uv](https://docs.astral.sh/uv/) - [uv](https://docs.astral.sh/uv/)
@@ -27,10 +35,12 @@ mc --help
## Basic Usage ## Basic Usage
```bash ```bash
# Create a new session with the default driver # Show help message (displays available commands)
# mc create session -- is the full command
mc mc
# Create a new session with the default driver
mc session create
# List all active sessions # List all active sessions
mc session list mc session list
@@ -50,17 +60,27 @@ mc session create -e VAR1=value1 -e VAR2=value2
mc session create -v /local/path:/container/path mc session create -v /local/path:/container/path
mc session create -v ~/data:/data -v ./configs:/etc/app/config mc session create -v ~/data:/data -v ./configs:/etc/app/config
# Mount a local directory (current directory or specific path)
mc session create .
mc session create /path/to/project
# Connect to external Docker networks # Connect to external Docker networks
mc session create --network teamnet --network dbnet mc session create --network teamnet --network dbnet
# Connect to MCP servers for extended capabilities # Connect to MCP servers for extended capabilities
mc session create --mcp github --mcp jira mc session create --mcp github --mcp jira
# Shorthand for creating a session with a project repository # Clone a Git repository
mc github.com/username/repo mc session create https://github.com/username/repo
# Using the mcx shortcut (equivalent to mc session create)
mcx # Creates a session without mounting anything
mcx . # Mounts the current directory
mcx /path/to/project # Mounts the specified directory
mcx https://github.com/username/repo # Clones the repository
# Shorthand with MCP servers # Shorthand with MCP servers
mc github.com/username/repo --mcp github mcx https://github.com/username/repo --mcp github
``` ```
## Driver Management ## Driver Management

View File

@@ -3,6 +3,7 @@ CLI for Monadical Container Tool.
""" """
import os import os
import logging
from typing import List, Optional from typing import List, Optional
import typer import typer
from rich.console import Console from rich.console import Console
@@ -15,11 +16,18 @@ from .user_config import UserConfigManager
from .session import SessionManager from .session import SessionManager
from .mcp import MCPManager from .mcp import MCPManager
app = typer.Typer(help="Monadical Container Tool") # Configure logging - will only show logs if --verbose flag is used
session_app = typer.Typer(help="Manage MC sessions") logging.basicConfig(
level=logging.WARNING, # Default to WARNING level
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler()],
)
app = typer.Typer(help="Monadical Container Tool", no_args_is_help=True)
session_app = typer.Typer(help="Manage MC sessions", no_args_is_help=True)
driver_app = typer.Typer(help="Manage MC drivers", no_args_is_help=True) driver_app = typer.Typer(help="Manage MC drivers", no_args_is_help=True)
config_app = typer.Typer(help="Manage MC configuration") config_app = typer.Typer(help="Manage MC configuration", no_args_is_help=True)
mcp_app = typer.Typer(help="Manage MCP servers") mcp_app = typer.Typer(help="Manage MCP servers", no_args_is_help=True)
app.add_typer(session_app, name="session", no_args_is_help=True) app.add_typer(session_app, name="session", no_args_is_help=True)
app.add_typer(driver_app, name="driver", no_args_is_help=True) app.add_typer(driver_app, name="driver", no_args_is_help=True)
app.add_typer(config_app, name="config", no_args_is_help=True) app.add_typer(config_app, name="config", no_args_is_help=True)
@@ -33,21 +41,21 @@ container_manager = ContainerManager(config_manager, session_manager, user_confi
mcp_manager = MCPManager(config_manager=user_config) mcp_manager = MCPManager(config_manager=user_config)
@app.callback(invoke_without_command=True) @app.callback()
def main(ctx: typer.Context) -> None: def main(
"""Monadical Container Tool""" ctx: typer.Context,
# If no command is specified, create a session verbose: bool = typer.Option(
if ctx.invoked_subcommand is None: False, "--verbose", "-v", help="Enable verbose logging"
create_session( ),
driver=None, ) -> None:
project=None, """Monadical Container Tool
env=[],
volume=[], Run 'mc session create' to create a new session.
network=[], Use 'mcx' as a shortcut for 'mc session create'.
name=None, """
no_connect=False, # Set log level based on verbose flag
no_mount=False, if verbose:
) logging.getLogger().setLevel(logging.INFO)
@app.command() @app.command()
@@ -120,8 +128,10 @@ def list_sessions() -> None:
@session_app.command("create") @session_app.command("create")
def create_session( def create_session(
driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"), driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"),
project: Optional[str] = typer.Option( project: Optional[str] = typer.Argument(
None, "--project", "-p", help="Project repository URL" None,
help="Local directory path to mount or repository URL to clone",
show_default=False,
), ),
env: List[str] = typer.Option( env: List[str] = typer.Option(
[], "--env", "-e", help="Environment variables (KEY=VALUE)" [], "--env", "-e", help="Environment variables (KEY=VALUE)"
@@ -136,11 +146,6 @@ def create_session(
no_connect: bool = typer.Option( no_connect: bool = typer.Option(
False, "--no-connect", help="Don't automatically connect to the session" False, "--no-connect", help="Don't automatically connect to the session"
), ),
no_mount: bool = typer.Option(
False,
"--no-mount",
help="Don't mount local directory to /app (ignored if --project is used)",
),
mcp: List[str] = typer.Option( mcp: List[str] = typer.Option(
[], [],
"--mcp", "--mcp",
@@ -148,7 +153,12 @@ def create_session(
help="Attach MCP servers to the session (can be specified multiple times)", help="Attach MCP servers to the session (can be specified multiple times)",
), ),
) -> None: ) -> None:
"""Create a new MC session""" """Create a new MC session
If a local directory path is provided, it will be mounted at /app in the container.
If a repository URL is provided, it will be cloned into /app during initialization.
If no path or URL is provided, no local volume will be mounted.
"""
# Use default driver from user configuration # Use default driver from user configuration
if not driver: if not driver:
driver = user_config.get( driver = user_config.get(
@@ -209,7 +219,7 @@ def create_session(
if not all_mcps: if not all_mcps:
default_mcps = user_config.get("defaults.mcps", []) default_mcps = user_config.get("defaults.mcps", [])
all_mcps = default_mcps all_mcps = default_mcps
if default_mcps: if default_mcps:
console.print(f"Using default MCP servers: {', '.join(default_mcps)}") console.print(f"Using default MCP servers: {', '.join(default_mcps)}")
@@ -223,12 +233,18 @@ def create_session(
console.print(f" {host_path} -> {mount_info['bind']}") console.print(f" {host_path} -> {mount_info['bind']}")
with console.status(f"Creating session with driver '{driver}'..."): with console.status(f"Creating session with driver '{driver}'..."):
# If project is a local directory, we should mount it
# If it's a Git URL or doesn't exist, handle accordingly
mount_local = False
if project and os.path.isdir(os.path.expanduser(project)):
mount_local = True
session = container_manager.create_session( session = container_manager.create_session(
driver_name=driver, driver_name=driver,
project=project, project=project,
environment=environment, environment=environment,
session_name=name, session_name=name,
mount_local=not no_mount and user_config.get("defaults.mount_local", True), mount_local=mount_local,
volumes=volume_mounts, volumes=volume_mounts,
networks=all_networks, networks=all_networks,
mcp=all_mcps, mcp=all_mcps,
@@ -362,61 +378,6 @@ def stop() -> None:
os.system("kill 1") # Send SIGTERM to PID 1 (container's init process) os.system("kill 1") # Send SIGTERM to PID 1 (container's init process)
# Main CLI entry point that handles project repository URLs
@app.command(name="")
def quick_create(
project: Optional[str] = typer.Argument(..., help="Project repository URL"),
driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"),
env: List[str] = typer.Option(
[], "--env", "-e", help="Environment variables (KEY=VALUE)"
),
volume: List[str] = typer.Option(
[], "--volume", "-v", help="Mount volumes (LOCAL_PATH:CONTAINER_PATH)"
),
network: List[str] = typer.Option(
[], "--network", "-N", help="Connect to additional Docker networks"
),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
no_connect: bool = typer.Option(
False, "--no-connect", help="Don't automatically connect to the session"
),
no_mount: bool = typer.Option(
False,
"--no-mount",
help="Don't mount local directory to /app (ignored if a project is specified)",
),
mcp: List[str] = typer.Option(
[],
"--mcp",
"-m",
help="Attach MCP servers to the session (can be specified multiple times)",
),
) -> None:
"""Create a new MC session with a project repository"""
# Use user config for defaults if not specified
if not driver:
driver = user_config.get("defaults.driver")
# Get default MCPs if none specified
all_mcps = mcp if isinstance(mcp, list) else []
if not all_mcps:
default_mcps = user_config.get("defaults.mcps", [])
if default_mcps:
all_mcps = default_mcps
create_session(
driver=driver,
project=project,
env=env,
volume=volume,
network=network,
name=name,
no_connect=no_connect,
no_mount=no_mount,
mcp=all_mcps,
)
@driver_app.command("list") @driver_app.command("list")
def list_drivers() -> None: def list_drivers() -> None:
"""List available MC drivers""" """List available MC drivers"""
@@ -537,6 +498,7 @@ config_app.add_typer(volume_app, name="volume", no_args_is_help=True)
config_mcp_app = typer.Typer(help="Manage default MCP servers") config_mcp_app = typer.Typer(help="Manage default MCP servers")
config_app.add_typer(config_mcp_app, name="mcp", no_args_is_help=True) config_app.add_typer(config_mcp_app, name="mcp", no_args_is_help=True)
# MCP configuration commands # MCP configuration commands
@config_mcp_app.command("list") @config_mcp_app.command("list")
def list_default_mcps() -> None: def list_default_mcps() -> None:
@@ -555,6 +517,7 @@ def list_default_mcps() -> None:
console.print(table) console.print(table)
@config_mcp_app.command("add") @config_mcp_app.command("add")
def add_default_mcp( def add_default_mcp(
name: str = typer.Argument(..., help="MCP server name to add to defaults"), name: str = typer.Argument(..., help="MCP server name to add to defaults"),
@@ -576,6 +539,7 @@ def add_default_mcp(
user_config.set("defaults.mcps", default_mcps) user_config.set("defaults.mcps", default_mcps)
console.print(f"[green]Added MCP server '{name}' to defaults[/green]") console.print(f"[green]Added MCP server '{name}' to defaults[/green]")
@config_mcp_app.command("remove") @config_mcp_app.command("remove")
def remove_default_mcp( def remove_default_mcp(
name: str = typer.Argument(..., help="MCP server name to remove from defaults"), name: str = typer.Argument(..., help="MCP server name to remove from defaults"),
@@ -1017,8 +981,15 @@ def mcp_status(name: str = typer.Argument(..., help="MCP server name")) -> None:
def start_mcp( def start_mcp(
name: Optional[str] = typer.Argument(None, help="MCP server name"), name: Optional[str] = typer.Argument(None, help="MCP server name"),
all_servers: bool = typer.Option(False, "--all", help="Start all MCP servers"), all_servers: bool = typer.Option(False, "--all", help="Start all MCP servers"),
verbose: bool = typer.Option(
False, "--verbose", "-v", help="Enable verbose logging"
),
) -> None: ) -> None:
"""Start an MCP server or all servers""" """Start an MCP server or all servers"""
# Set log level based on verbose flag
if verbose:
logging.getLogger().setLevel(logging.INFO)
# Check if we need to start all servers # Check if we need to start all servers
if all_servers: if all_servers:
# Get all configured MCP servers # Get all configured MCP servers
@@ -1281,6 +1252,26 @@ def mcp_logs(
def remove_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None: def remove_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None:
"""Remove an MCP server configuration""" """Remove an MCP server configuration"""
try: try:
# Check if any active sessions might be using this MCP
active_sessions = container_manager.list_sessions()
affected_sessions = []
for session in active_sessions:
if session.mcps and name in session.mcps:
affected_sessions.append(session)
# Just warn users about affected sessions
if affected_sessions:
console.print(
f"[yellow]Warning: Found {len(affected_sessions)} active sessions using MCP '{name}'[/yellow]"
)
console.print(
"[yellow]You may need to restart these sessions for changes to take effect:[/yellow]"
)
for session in affected_sessions:
console.print(f" - Session: {session.id} ({session.name})")
# Remove the MCP from configuration
with console.status(f"Removing MCP server '{name}'..."): with console.status(f"Removing MCP server '{name}'..."):
result = mcp_manager.remove_mcp(name) result = mcp_manager.remove_mcp(name)
@@ -1365,7 +1356,7 @@ def add_mcp(
console.print( console.print(
f"Container port {sse_port} will be bound to host port {assigned_port}" f"Container port {sse_port} will be bound to host port {assigned_port}"
) )
if not no_default: if not no_default:
console.print(f"MCP server '{name}' added to defaults") console.print(f"MCP server '{name}' added to defaults")
else: else:
@@ -1400,10 +1391,12 @@ def add_remote_mcp(
try: try:
with console.status(f"Adding remote MCP server '{name}'..."): with console.status(f"Adding remote MCP server '{name}'..."):
mcp_manager.add_remote_mcp(name, url, headers, add_as_default=not no_default) mcp_manager.add_remote_mcp(
name, url, headers, add_as_default=not no_default
)
console.print(f"[green]Added remote MCP server '{name}'[/green]") console.print(f"[green]Added remote MCP server '{name}'[/green]")
if not no_default: if not no_default:
console.print(f"MCP server '{name}' added to defaults") console.print(f"MCP server '{name}' added to defaults")
else: else:
@@ -1861,5 +1854,27 @@ exec npm start
console.print("[green]MCP Inspector stopped[/green]") console.print("[green]MCP Inspector stopped[/green]")
def session_create_entry_point():
"""Entry point that directly invokes 'mc session create'.
This provides a convenient shortcut:
- 'mcx' runs as if you typed 'mc session create'
- 'mcx .' mounts the current directory
- 'mcx /path/to/project' mounts the specified directory
- 'mcx repo-url' clones the repository
All command-line options are passed through to 'session create'.
"""
import sys
# Save the program name (e.g., 'mcx')
prog_name = sys.argv[0]
# Insert 'session' and 'create' commands before any other arguments
sys.argv.insert(1, "session")
sys.argv.insert(2, "create")
# Run the app with the modified arguments
app(prog_name=prog_name)
if __name__ == "__main__": if __name__ == "__main__":
app() app()

View File

@@ -141,7 +141,7 @@ class ContainerManager:
project: Optional[str] = None, project: Optional[str] = None,
environment: Optional[Dict[str, str]] = None, environment: Optional[Dict[str, str]] = None,
session_name: Optional[str] = None, session_name: Optional[str] = None,
mount_local: bool = True, mount_local: bool = False,
volumes: Optional[Dict[str, Dict[str, str]]] = None, volumes: Optional[Dict[str, Dict[str, str]]] = None,
networks: Optional[List[str]] = None, networks: Optional[List[str]] = None,
mcp: Optional[List[str]] = None, mcp: Optional[List[str]] = None,
@@ -150,10 +150,10 @@ class ContainerManager:
Args: Args:
driver_name: The name of the driver to use driver_name: The name of the driver to use
project: Optional project repository URL project: Optional project repository URL or local directory path
environment: Optional environment variables environment: Optional environment variables
session_name: Optional session name session_name: Optional session name
mount_local: Whether to mount the current directory to /app mount_local: Whether to mount the specified local directory to /app (ignored if project is None)
volumes: Optional additional volumes to mount (dict of {host_path: {"bind": container_path, "mode": mode}}) volumes: Optional additional volumes to mount (dict of {host_path: {"bind": container_path, "mode": mode}})
networks: Optional list of additional Docker networks to connect to networks: Optional list of additional Docker networks to connect to
mcp: Optional list of MCP server names to attach to the session mcp: Optional list of MCP server names to attach to the session
@@ -203,16 +203,29 @@ class ContainerManager:
# Set up volume mounts # Set up volume mounts
session_volumes = {} session_volumes = {}
# If project URL is provided, don't mount local directory (will clone into /app) # Determine if project is a local directory or a Git repository
# If no project URL and mount_local is True, mount local directory to /app is_local_directory = False
if not project and mount_local: is_git_repo = False
# Mount current directory to /app in the container
current_dir = os.getcwd() if project:
session_volumes[current_dir] = {"bind": "/app", "mode": "rw"} # Check if project is a local directory
print(f"Mounting local directory {current_dir} to /app") if os.path.isdir(os.path.expanduser(project)):
elif project: is_local_directory = True
else:
# If not a local directory, assume it's a Git repo URL
is_git_repo = True
# Handle mounting based on project type
if is_local_directory and mount_local:
# Mount the specified local directory to /app in the container
local_dir = os.path.abspath(os.path.expanduser(project))
session_volumes[local_dir] = {"bind": "/app", "mode": "rw"}
print(f"Mounting local directory {local_dir} to /app")
# Clear project for container environment since we're mounting
project = None
elif is_git_repo:
print( print(
f"Project URL provided - container will clone {project} into /app during initialization" f"Git repository URL provided - container will clone {project} into /app during initialization"
) )
# Add user-specified volumes # Add user-specified volumes
@@ -220,13 +233,13 @@ class ContainerManager:
for host_path, mount_spec in volumes.items(): for host_path, mount_spec in volumes.items():
container_path = mount_spec["bind"] container_path = mount_spec["bind"]
# Check for conflicts with /app mount # Check for conflicts with /app mount
if container_path == "/app" and not project and mount_local: if container_path == "/app" and is_local_directory and mount_local:
print( print(
"[yellow]Warning: Volume mount to /app conflicts with automatic local directory mount. User-specified mount takes precedence.[/yellow]" "[yellow]Warning: Volume mount to /app conflicts with local directory mount. User-specified mount takes precedence.[/yellow]"
) )
# Remove the automatic mount if there's a conflict # Remove the local directory mount if there's a conflict
if current_dir in session_volumes: if local_dir in session_volumes:
del session_volumes[current_dir] del session_volumes[local_dir]
# Add the volume # Add the volume
session_volumes[host_path] = mount_spec session_volumes[host_path] = mount_spec
@@ -309,9 +322,11 @@ class ContainerManager:
try: try:
print(f"Ensuring MCP server '{mcp_name}' is running...") print(f"Ensuring MCP server '{mcp_name}' is running...")
self.mcp_manager.start_mcp(mcp_name) self.mcp_manager.start_mcp(mcp_name)
# Store container name for later network connection # Store container name for later network connection
container_name = self.mcp_manager.get_mcp_container_name(mcp_name) container_name = self.mcp_manager.get_mcp_container_name(
mcp_name
)
mcp_container_names.append(container_name) mcp_container_names.append(container_name)
# Get MCP status to extract endpoint information # Get MCP status to extract endpoint information
@@ -438,25 +453,29 @@ class ContainerManager:
) )
except DockerException as e: except DockerException as e:
print(f"Error connecting to network {network_name}: {e}") print(f"Error connecting to network {network_name}: {e}")
# Reload the container to get updated network information # Reload the container to get updated network information
container.reload() container.reload()
# Connect directly to each MCP's dedicated network # Connect directly to each MCP's dedicated network
for mcp_name in mcp_names: for mcp_name in mcp_names:
try: try:
# Get the dedicated network for this MCP # Get the dedicated network for this MCP
dedicated_network_name = f"mc-mcp-{mcp_name}-network" dedicated_network_name = f"mc-mcp-{mcp_name}-network"
try: try:
network = self.client.networks.get(dedicated_network_name) network = self.client.networks.get(dedicated_network_name)
# Connect the session container to the MCP's dedicated network # Connect the session container to the MCP's dedicated network
network.connect(container, aliases=[session_name]) network.connect(container, aliases=[session_name])
print(f"Connected session to MCP '{mcp_name}' via dedicated network: {dedicated_network_name}") print(
f"Connected session to MCP '{mcp_name}' via dedicated network: {dedicated_network_name}"
)
except DockerException as e: except DockerException as e:
print(f"Error connecting to MCP dedicated network '{dedicated_network_name}': {e}") print(
f"Error connecting to MCP dedicated network '{dedicated_network_name}': {e}"
)
except Exception as e: except Exception as e:
print(f"Error connecting session to MCP '{mcp_name}': {e}") print(f"Error connecting session to MCP '{mcp_name}': {e}")
@@ -464,7 +483,13 @@ class ContainerManager:
if networks: if networks:
for network_name in networks: for network_name in networks:
# Check if already connected to this network # Check if already connected to this network
if network_name not in [net.name for net in container.attrs.get("NetworkSettings", {}).get("Networks", {}).values()]: # NetworkSettings.Networks contains a dict where keys are network names
existing_networks = (
container.attrs.get("NetworkSettings", {})
.get("Networks", {})
.keys()
)
if network_name not in existing_networks:
try: try:
# Get or create the network # Get or create the network
try: try:

View File

@@ -42,7 +42,8 @@ COPY update-goose-config.sh /usr/local/bin/update-goose-config.sh
# Extend env via bashrc # Extend env via bashrc
# Make scripts executable # Make scripts executable
RUN chmod +x /mc-init.sh /entrypoint.sh /init-status.sh /usr/local/bin/update-goose-config.sh RUN chmod +x /mc-init.sh /entrypoint.sh /init-status.sh \
/usr/local/bin/update-goose-config.sh
# Set up initialization status check on login # Set up initialization status check on login
RUN echo 'export PATH=/root/.local/bin:$PATH' >> /etc/bash.bashrc RUN echo 'export PATH=/root/.local/bin:$PATH' >> /etc/bash.bashrc

View File

@@ -43,13 +43,13 @@ class MCPManager:
if not networks: if not networks:
self.client.networks.create(network_name, driver="bridge") self.client.networks.create(network_name, driver="bridge")
return network_name return network_name
def _get_mcp_dedicated_network(self, mcp_name: str) -> str: def _get_mcp_dedicated_network(self, mcp_name: str) -> str:
"""Get or create a dedicated network for direct session-to-MCP connections. """Get or create a dedicated network for direct session-to-MCP connections.
Args: Args:
mcp_name: The name of the MCP server mcp_name: The name of the MCP server
Returns: Returns:
The name of the dedicated network The name of the dedicated network
""" """
@@ -74,16 +74,20 @@ class MCPManager:
return None return None
def add_remote_mcp( def add_remote_mcp(
self, name: str, url: str, headers: Dict[str, str] = None, add_as_default: bool = True self,
name: str,
url: str,
headers: Dict[str, str] = None,
add_as_default: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Add a remote MCP server. """Add a remote MCP server.
Args: Args:
name: Name of the MCP server name: Name of the MCP server
url: URL of the remote MCP server url: URL of the remote MCP server
headers: HTTP headers to use when connecting headers: HTTP headers to use when connecting
add_as_default: Whether to add this MCP to the default MCPs list add_as_default: Whether to add this MCP to the default MCPs list
Returns: Returns:
The MCP configuration dictionary The MCP configuration dictionary
""" """
@@ -106,7 +110,7 @@ class MCPManager:
# Save the configuration # Save the configuration
self.config_manager.set("mcps", mcps) self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested # Add to default MCPs if requested
if add_as_default: if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", []) default_mcps = self.config_manager.get("defaults.mcps", [])
@@ -117,17 +121,22 @@ class MCPManager:
return mcp_config return mcp_config
def add_docker_mcp( def add_docker_mcp(
self, name: str, image: str, command: str, env: Dict[str, str] = None, add_as_default: bool = True self,
name: str,
image: str,
command: str,
env: Dict[str, str] = None,
add_as_default: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Add a Docker-based MCP server. """Add a Docker-based MCP server.
Args: Args:
name: Name of the MCP server name: Name of the MCP server
image: Docker image for the MCP server image: Docker image for the MCP server
command: Command to run in the container command: Command to run in the container
env: Environment variables to set in the container env: Environment variables to set in the container
add_as_default: Whether to add this MCP to the default MCPs list add_as_default: Whether to add this MCP to the default MCPs list
Returns: Returns:
The MCP configuration dictionary The MCP configuration dictionary
""" """
@@ -151,7 +160,7 @@ class MCPManager:
# Save the configuration # Save the configuration
self.config_manager.set("mcps", mcps) self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested # Add to default MCPs if requested
if add_as_default: if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", []) default_mcps = self.config_manager.get("defaults.mcps", [])
@@ -173,7 +182,7 @@ class MCPManager:
add_as_default: bool = True, add_as_default: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Add a proxy-based MCP server. """Add a proxy-based MCP server.
Args: Args:
name: Name of the MCP server name: Name of the MCP server
base_image: Base Docker image running the actual MCP server base_image: Base Docker image running the actual MCP server
@@ -183,7 +192,7 @@ class MCPManager:
env: Environment variables to set in the container env: Environment variables to set in the container
host_port: Host port to bind the MCP server to (auto-assigned if not specified) 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 add_as_default: Whether to add this MCP to the default MCPs list
Returns: Returns:
The MCP configuration dictionary The MCP configuration dictionary
""" """
@@ -228,7 +237,7 @@ class MCPManager:
# Save the configuration # Save the configuration
self.config_manager.set("mcps", mcps) self.config_manager.set("mcps", mcps)
# Add to default MCPs if requested # Add to default MCPs if requested
if add_as_default: if add_as_default:
default_mcps = self.config_manager.get("defaults.mcps", []) default_mcps = self.config_manager.get("defaults.mcps", [])
@@ -240,10 +249,10 @@ class MCPManager:
def remove_mcp(self, name: str) -> bool: def remove_mcp(self, name: str) -> bool:
"""Remove an MCP server configuration. """Remove an MCP server configuration.
Args: Args:
name: Name of the MCP server to remove name: Name of the MCP server to remove
Returns: Returns:
True if the MCP was successfully removed, False otherwise True if the MCP was successfully removed, False otherwise
""" """
@@ -258,7 +267,7 @@ class MCPManager:
# Save the updated configuration # Save the updated configuration
self.config_manager.set("mcps", updated_mcps) self.config_manager.set("mcps", updated_mcps)
# Also remove from default MCPs if it's there # Also remove from default MCPs if it's there
default_mcps = self.config_manager.get("defaults.mcps", []) default_mcps = self.config_manager.get("defaults.mcps", [])
if name in default_mcps: if name in default_mcps:
@@ -370,20 +379,22 @@ class MCPManager:
}, },
) )
# Connect to the inspector network # Connect to the inspector network
network = self.client.networks.get(network_name) network = self.client.networks.get(network_name)
network.connect(container, aliases=[name]) network.connect(container, aliases=[name])
logger.info( logger.info(
f"Connected MCP server '{name}' to inspector network {network_name} with alias '{name}'" f"Connected MCP server '{name}' to inspector network {network_name} with alias '{name}'"
) )
# Create and connect to a dedicated network for session connections # Create and connect to a dedicated network for session connections
dedicated_network_name = self._get_mcp_dedicated_network(name) dedicated_network_name = self._get_mcp_dedicated_network(name)
try: try:
dedicated_network = self.client.networks.get(dedicated_network_name) dedicated_network = self.client.networks.get(dedicated_network_name)
except DockerException: except DockerException:
dedicated_network = self.client.networks.create(dedicated_network_name, driver="bridge") dedicated_network = self.client.networks.create(
dedicated_network_name, driver="bridge"
)
dedicated_network.connect(container, aliases=[name]) dedicated_network.connect(container, aliases=[name])
logger.info( logger.info(
f"Connected MCP server '{name}' to dedicated network {dedicated_network_name} with alias '{name}'" f"Connected MCP server '{name}' to dedicated network {dedicated_network_name} with alias '{name}'"
@@ -400,6 +411,7 @@ class MCPManager:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
# Create entrypoint script for mcp-proxy that runs the base MCP image # Create entrypoint script for mcp-proxy that runs the base MCP image
entrypoint_script = """#!/bin/sh entrypoint_script = """#!/bin/sh
set -x
echo "Starting MCP proxy with base image $MCP_BASE_IMAGE (command: $MCP_COMMAND) on port $SSE_PORT" echo "Starting MCP proxy with base image $MCP_BASE_IMAGE (command: $MCP_COMMAND) on port $SSE_PORT"
# Verify if Docker socket is available # Verify if Docker socket is available
@@ -453,19 +465,44 @@ 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 # 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 # 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 \ exec mcp-proxy \
--sse-port "$SSE_PORT" \ --sse-port "$SSE_PORT" \
--sse-host "$SSE_HOST" \ --sse-host "$SSE_HOST" \
--allow-origin "$ALLOW_ORIGIN" \ --allow-origin "$ALLOW_ORIGIN" \
--pass-environment \ --pass-environment \
-- \ -- \
docker run --rm -i "$MCP_BASE_IMAGE" $CMD docker run --rm -i $ENV_ARGS "$MCP_BASE_IMAGE" $CMD
""" """
# Write the entrypoint script # Write the entrypoint script
entrypoint_path = os.path.join(tmp_dir, "entrypoint.sh") entrypoint_path = os.path.join(tmp_dir, "entrypoint.sh")
with open(entrypoint_path, "w") as f: with open(entrypoint_path, "w") as f:
f.write(entrypoint_script) 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 # Create a Dockerfile for the proxy
dockerfile_content = f""" dockerfile_content = f"""
FROM {mcp_config["proxy_image"]} FROM {mcp_config["proxy_image"]}
@@ -489,7 +526,8 @@ ENV DEBUG=1
# Add environment variables from the configuration # Add environment variables from the configuration
{chr(10).join([f'ENV {k}="{v}"' for k, v in mcp_config.get("env", {}).items()])} {chr(10).join([f'ENV {k}="{v}"' for k, v in mcp_config.get("env", {}).items()])}
# Add entrypoint script # Add env names file and entrypoint script
COPY mcp-envs.txt /mcp-envs.txt
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
@@ -545,20 +583,22 @@ ENTRYPOINT ["/entrypoint.sh"]
ports=port_bindings, # Bind the SSE port to the host if configured ports=port_bindings, # Bind the SSE port to the host if configured
) )
# Connect to the inspector network # Connect to the inspector network
network = self.client.networks.get(network_name) network = self.client.networks.get(network_name)
network.connect(container, aliases=[name]) network.connect(container, aliases=[name])
logger.info( logger.info(
f"Connected MCP server '{name}' to inspector network {network_name} with alias '{name}'" f"Connected MCP server '{name}' to inspector network {network_name} with alias '{name}'"
) )
# Create and connect to a dedicated network for session connections # Create and connect to a dedicated network for session connections
dedicated_network_name = self._get_mcp_dedicated_network(name) dedicated_network_name = self._get_mcp_dedicated_network(name)
try: try:
dedicated_network = self.client.networks.get(dedicated_network_name) dedicated_network = self.client.networks.get(dedicated_network_name)
except DockerException: except DockerException:
dedicated_network = self.client.networks.create(dedicated_network_name, driver="bridge") dedicated_network = self.client.networks.create(
dedicated_network_name, driver="bridge"
)
dedicated_network.connect(container, aliases=[name]) dedicated_network.connect(container, aliases=[name])
logger.info( logger.info(
f"Connected MCP server '{name}' to dedicated network {dedicated_network_name} with alias '{name}'" f"Connected MCP server '{name}' to dedicated network {dedicated_network_name} with alias '{name}'"
@@ -574,14 +614,25 @@ ENTRYPOINT ["/entrypoint.sh"]
raise ValueError(f"Unsupported MCP type: {mcp_type}") raise ValueError(f"Unsupported MCP type: {mcp_type}")
def stop_mcp(self, name: str) -> bool: def stop_mcp(self, name: str) -> bool:
"""Stop an MCP server container.""" """Stop an MCP server container.
if not self.client:
raise Exception("Docker client is not available")
# Get the MCP configuration 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) mcp_config = self.get_mcp(name)
if not mcp_config: if not mcp_config:
raise ValueError(f"MCP server '{name}' not found") logger.warning(
f"MCP server '{name}' not found, but continuing with removal"
)
return True
# Remote MCPs don't have containers to stop # Remote MCPs don't have containers to stop
if mcp_config.get("type") == "remote": if mcp_config.get("type") == "remote":
@@ -605,12 +656,13 @@ ENTRYPOINT ["/entrypoint.sh"]
return True return True
except NotFound: except NotFound:
# Container doesn't exist # Container doesn't exist - this is fine when removing
logger.info(f"MCP container '{name}' not found, nothing to stop or remove") logger.info(f"MCP container '{name}' not found, nothing to stop or remove")
return False return True
except Exception as e: except Exception as e:
# Log the error but don't fail the removal operation
logger.error(f"Error stopping/removing MCP container: {e}") logger.error(f"Error stopping/removing MCP container: {e}")
return False return True # Return true anyway to continue with removal
def restart_mcp(self, name: str) -> Dict[str, Any]: def restart_mcp(self, name: str) -> Dict[str, Any]:
"""Restart an MCP server container.""" """Restart an MCP server container."""

View File

@@ -25,6 +25,7 @@ dev = [
[project.scripts] [project.scripts]
mc = "mcontainer.cli:app" mc = "mcontainer.cli:app"
mcx = "mcontainer.cli:session_create_entry_point"
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 88