feat(mcp): add inspector

This commit is contained in:
2025-03-25 21:35:35 +01:00
parent 0892b6c8c4
commit d098f268cd
6 changed files with 941 additions and 78 deletions

View File

@@ -1,3 +1,7 @@
"""
CLI for Monadical Container Tool.
"""
import os
from typing import List, Optional
import typer
@@ -784,6 +788,7 @@ def list_mcps() -> None:
table.add_column("Name")
table.add_column("Type")
table.add_column("Status")
table.add_column("Ports")
table.add_column("Details")
# Check status of each MCP
@@ -804,6 +809,24 @@ def list_mcps() -> None:
"failed": "red",
}.get(status, "white")
# Get port information
ports_info = ""
if mcp_type == "proxy" and status == "running":
# For running proxy MCP, show the bound ports
container_ports = status_info.get("ports", {})
if container_ports:
port_mappings = []
for container_port, host_port in container_ports.items():
if host_port:
port_mappings.append(f"{host_port}{container_port}")
if port_mappings:
ports_info = ", ".join(port_mappings)
# For non-running proxy MCP, show the configured host port
if not ports_info and mcp_type == "proxy" and mcp.get("host_port"):
sse_port = mcp.get("proxy_options", {}).get("sse_port", 8080)
ports_info = f"{mcp.get('host_port')}{sse_port}/tcp (configured)"
# Different details based on MCP type
if mcp_type == "remote":
details = mcp.get("url", "")
@@ -820,6 +843,7 @@ def list_mcps() -> None:
name,
mcp_type,
f"[{status_color}]{status}[/{status_color}]",
ports_info,
details,
)
except Exception as e:
@@ -827,6 +851,7 @@ def list_mcps() -> None:
name,
mcp_type,
"[red]error[/red]",
"", # Empty ports column for error
str(e),
)
@@ -884,9 +909,14 @@ def mcp_status(name: str = typer.Argument(..., help="MCP server name")) -> None:
f"[bold]Container ID:[/bold] {status_info.get('container_id')}"
)
if status_info.get("ports"):
console.print("[bold]Ports:[/bold]")
console.print("[bold]Container Ports:[/bold]")
for port, host_port in status_info.get("ports", {}).items():
console.print(f" {port} -> {host_port}")
if host_port:
console.print(
f" {port} -> [green]bound to host port {host_port}[/green]"
)
else:
console.print(f" {port} (internal only)")
if status_info.get("created"):
console.print(f"[bold]Created:[/bold] {status_info.get('created')}")
@@ -898,6 +928,14 @@ def mcp_status(name: str = typer.Argument(..., help="MCP server name")) -> None:
console.print(
f"[bold]Proxy Image:[/bold] {mcp_config.get('proxy_image')}"
)
# Show configured host port binding
if mcp_config.get("host_port"):
sse_port = mcp_config.get("proxy_options", {}).get("sse_port", 8080)
console.print(
f"[bold]Port Binding:[/bold] Container port {sse_port}/tcp -> Host port {mcp_config.get('host_port')}"
)
console.print("[bold]Proxy Options:[/bold]")
for key, value in mcp_config.get("proxy_options", {}).items():
console.print(f" {key}: {value}")
@@ -907,59 +945,245 @@ def mcp_status(name: str = typer.Argument(..., help="MCP server name")) -> None:
@mcp_app.command("start")
def start_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None:
"""Start an MCP server"""
try:
with console.status(f"Starting MCP server '{name}'..."):
result = mcp_manager.start_mcp(name)
def start_mcp(
name: Optional[str] = typer.Argument(None, help="MCP server name"),
all_servers: bool = typer.Option(False, "--all", help="Start all MCP servers"),
) -> None:
"""Start an MCP server or all servers"""
# Check if we need to start all servers
if all_servers:
# Get all configured MCP servers
mcps = mcp_manager.list_mcps()
if result.get("status") == "running":
console.print(f"[green]Started MCP server '{name}'[/green]")
elif result.get("status") == "not_applicable":
if not mcps:
console.print("[yellow]No MCP servers configured[/yellow]")
return
# Count of successfully started servers
started_count = 0
remote_count = 0
failed_count = 0
console.print(f"Starting {len(mcps)} MCP servers...")
for mcp in mcps:
mcp_name = mcp.get("name")
if not mcp_name:
continue
try:
with console.status(f"Starting MCP server '{mcp_name}'..."):
result = mcp_manager.start_mcp(mcp_name)
if result.get("status") == "running":
console.print(f"[green]Started MCP server '{mcp_name}'[/green]")
started_count += 1
elif result.get("status") == "not_applicable":
console.print(
f"[blue]MCP server '{mcp_name}' is a remote type (no container to start)[/blue]"
)
remote_count += 1
else:
console.print(
f"MCP server '{mcp_name}' status: {result.get('status')}"
)
failed_count += 1
except Exception as e:
console.print(f"[red]Error starting MCP server '{mcp_name}': {e}[/red]")
failed_count += 1
# Show a summary
if started_count > 0:
console.print(
f"[blue]MCP server '{name}' is a remote type (no container to start)[/blue]"
f"[green]Successfully started {started_count} MCP servers[/green]"
)
else:
console.print(f"MCP server '{name}' status: {result.get('status')}")
if remote_count > 0:
console.print(
f"[blue]{remote_count} remote MCP servers (no action needed)[/blue]"
)
if failed_count > 0:
console.print(f"[red]Failed to start {failed_count} MCP servers[/red]")
except Exception as e:
console.print(f"[red]Error starting MCP server: {e}[/red]")
# Otherwise start a specific server
elif name:
try:
with console.status(f"Starting MCP server '{name}'..."):
result = mcp_manager.start_mcp(name)
if result.get("status") == "running":
console.print(f"[green]Started MCP server '{name}'[/green]")
elif result.get("status") == "not_applicable":
console.print(
f"[blue]MCP server '{name}' is a remote type (no container to start)[/blue]"
)
else:
console.print(f"MCP server '{name}' status: {result.get('status')}")
except Exception as e:
console.print(f"[red]Error starting MCP server: {e}[/red]")
else:
console.print(
"[red]Error: Please provide a server name or use --all to start all servers[/red]"
)
@mcp_app.command("stop")
def stop_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None:
"""Stop an MCP server"""
try:
with console.status(f"Stopping MCP server '{name}'..."):
result = mcp_manager.stop_mcp(name)
def stop_mcp(
name: Optional[str] = typer.Argument(None, help="MCP server name"),
all_servers: bool = typer.Option(False, "--all", help="Stop all MCP servers"),
) -> None:
"""Stop an MCP server or all servers"""
# Check if we need to stop all servers
if all_servers:
# Get all configured MCP servers
mcps = mcp_manager.list_mcps()
if result:
console.print(f"[green]Stopped MCP server '{name}'[/green]")
else:
console.print(f"[yellow]MCP server '{name}' was not running[/yellow]")
if not mcps:
console.print("[yellow]No MCP servers configured[/yellow]")
return
except Exception as e:
console.print(f"[red]Error stopping MCP server: {e}[/red]")
# Count of successfully stopped servers
stopped_count = 0
not_running_count = 0
failed_count = 0
console.print(f"Stopping {len(mcps)} MCP servers...")
for mcp in mcps:
mcp_name = mcp.get("name")
if not mcp_name:
continue
try:
with console.status(f"Stopping MCP server '{mcp_name}'..."):
result = mcp_manager.stop_mcp(mcp_name)
if result:
console.print(f"[green]Stopped MCP server '{mcp_name}'[/green]")
stopped_count += 1
else:
console.print(
f"[yellow]MCP server '{mcp_name}' was not running[/yellow]"
)
not_running_count += 1
except Exception as e:
console.print(f"[red]Error stopping MCP server '{mcp_name}': {e}[/red]")
failed_count += 1
# Show a summary
if stopped_count > 0:
console.print(
f"[green]Successfully stopped {stopped_count} MCP servers[/green]"
)
if not_running_count > 0:
console.print(
f"[yellow]{not_running_count} MCP servers were not running[/yellow]"
)
if failed_count > 0:
console.print(f"[red]Failed to stop {failed_count} MCP servers[/red]")
# Otherwise stop a specific server
elif name:
try:
with console.status(f"Stopping MCP server '{name}'..."):
result = mcp_manager.stop_mcp(name)
if result:
console.print(f"[green]Stopped MCP server '{name}'[/green]")
else:
console.print(f"[yellow]MCP server '{name}' was not running[/yellow]")
except Exception as e:
console.print(f"[red]Error stopping MCP server: {e}[/red]")
else:
console.print(
"[red]Error: Please provide a server name or use --all to stop all servers[/red]"
)
@mcp_app.command("restart")
def restart_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None:
"""Restart an MCP server"""
try:
with console.status(f"Restarting MCP server '{name}'..."):
result = mcp_manager.restart_mcp(name)
def restart_mcp(
name: Optional[str] = typer.Argument(None, help="MCP server name"),
all_servers: bool = typer.Option(False, "--all", help="Restart all MCP servers"),
) -> None:
"""Restart an MCP server or all servers"""
# Check if we need to restart all servers
if all_servers:
# Get all configured MCP servers
mcps = mcp_manager.list_mcps()
if result.get("status") == "running":
console.print(f"[green]Restarted MCP server '{name}'[/green]")
elif result.get("status") == "not_applicable":
if not mcps:
console.print("[yellow]No MCP servers configured[/yellow]")
return
# Count of successfully restarted servers
restarted_count = 0
remote_count = 0
failed_count = 0
console.print(f"Restarting {len(mcps)} MCP servers...")
for mcp in mcps:
mcp_name = mcp.get("name")
if not mcp_name:
continue
try:
with console.status(f"Restarting MCP server '{mcp_name}'..."):
result = mcp_manager.restart_mcp(mcp_name)
if result.get("status") == "running":
console.print(f"[green]Restarted MCP server '{mcp_name}'[/green]")
restarted_count += 1
elif result.get("status") == "not_applicable":
console.print(
f"[blue]MCP server '{mcp_name}' is a remote type (no container to restart)[/blue]"
)
remote_count += 1
else:
console.print(
f"MCP server '{mcp_name}' status: {result.get('status')}"
)
failed_count += 1
except Exception as e:
console.print(
f"[red]Error restarting MCP server '{mcp_name}': {e}[/red]"
)
failed_count += 1
# Show a summary
if restarted_count > 0:
console.print(
f"[blue]MCP server '{name}' is a remote type (no container to restart)[/blue]"
f"[green]Successfully restarted {restarted_count} MCP servers[/green]"
)
else:
console.print(f"MCP server '{name}' status: {result.get('status')}")
if remote_count > 0:
console.print(
f"[blue]{remote_count} remote MCP servers (no action needed)[/blue]"
)
if failed_count > 0:
console.print(f"[red]Failed to restart {failed_count} MCP servers[/red]")
except Exception as e:
console.print(f"[red]Error restarting MCP server: {e}[/red]")
# Otherwise restart a specific server
elif name:
try:
with console.status(f"Restarting MCP server '{name}'..."):
result = mcp_manager.restart_mcp(name)
if result.get("status") == "running":
console.print(f"[green]Restarted MCP server '{name}'[/green]")
elif result.get("status") == "not_applicable":
console.print(
f"[blue]MCP server '{name}' is a remote type (no container to restart)[/blue]"
)
else:
console.print(f"MCP server '{name}' status: {result.get('status')}")
except Exception as e:
console.print(f"[red]Error restarting MCP server: {e}[/red]")
else:
console.print(
"[red]Error: Please provide a server name or use --all to restart all servers[/red]"
)
@mcp_app.command("logs")
@@ -1004,11 +1228,19 @@ def add_mcp(
command: str = typer.Option(
"", "--command", "-c", help="Command to run in the container"
),
sse_port: int = typer.Option(8080, "--sse-port", help="Port for SSE server"),
sse_port: int = typer.Option(
8080, "--sse-port", help="Port for SSE server inside container"
),
sse_host: str = typer.Option("0.0.0.0", "--sse-host", help="Host for SSE server"),
allow_origin: str = typer.Option(
"*", "--allow-origin", help="CORS allow-origin header"
),
host_port: Optional[int] = typer.Option(
None,
"--host-port",
"-p",
help="Host port to bind the MCP server to (auto-assigned if not specified)",
),
env: List[str] = typer.Option(
[], "--env", "-e", help="Environment variables (format: KEY=VALUE)"
),
@@ -1034,11 +1266,24 @@ def add_mcp(
try:
with console.status(f"Adding MCP server '{name}'..."):
mcp_manager.add_proxy_mcp(
name, base_image, proxy_image, command, proxy_options, environment
result = mcp_manager.add_proxy_mcp(
name,
base_image,
proxy_image,
command,
proxy_options,
environment,
host_port,
)
# Get the assigned port
assigned_port = result.get("host_port")
console.print(f"[green]Added MCP server '{name}'[/green]")
if assigned_port:
console.print(
f"Container port {sse_port} will be bound to host port {assigned_port}"
)
except Exception as e:
console.print(f"[red]Error adding MCP server: {e}[/red]")
@@ -1074,5 +1319,406 @@ def add_remote_mcp(
console.print(f"[red]Error adding remote MCP server: {e}[/red]")
@mcp_app.command("inspector")
def run_mcp_inspector(
host_port: int = typer.Option(
5173, "--port", "-p", help="Host port for the MCP Inspector"
),
detach: bool = typer.Option(False, "--detach", "-d", help="Run in detached mode"),
stop: bool = typer.Option(False, "--stop", help="Stop running MCP Inspector(s)"),
) -> None:
"""Run the MCP Inspector to visualize and debug MCP servers"""
import docker
import time
# Get Docker client quietly
try:
client = docker.from_env()
except Exception as e:
console.print(f"[red]Error connecting to Docker: {e}[/red]")
return
# If stop flag is set, stop all running MCP Inspectors
if stop:
containers = client.containers.list(
all=True, filters={"label": "mc.mcp.inspector=true"}
)
if not containers:
console.print("[yellow]No running MCP Inspector instances found[/yellow]")
return
with console.status("Stopping MCP Inspector..."):
for container in containers:
try:
container.stop()
container.remove(force=True)
except Exception:
pass
console.print("[green]MCP Inspector stopped[/green]")
return
# Check if inspector is already running
all_inspectors = client.containers.list(
all=True, filters={"label": "mc.mcp.inspector=true"}
)
# Stop any existing inspectors first
for inspector in all_inspectors:
try:
if inspector.status == "running":
inspector.stop(timeout=1)
inspector.remove(force=True)
except Exception as e:
console.print(
f"[yellow]Warning: Could not remove existing inspector: {e}[/yellow]"
)
# Check if the specified port is already in use
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("0.0.0.0", host_port))
s.close()
except socket.error:
console.print(
f"[red]Error: Port {host_port} is already in use by another process.[/red]"
)
console.print("Please stop any web servers or other processes using this port.")
console.print("You can try a different port with --port option")
return
# Container name with timestamp to avoid conflicts
container_name = f"mc_mcp_inspector_{int(time.time())}"
with console.status("Starting MCP Inspector..."):
# Get MCP servers from configuration
all_mcps = mcp_manager.list_mcps()
# Get all MCP server URLs (including remote ones)
mcp_servers = []
# Collect networks that need to be connected to the Inspector
mcp_networks_to_connect = []
# Add remote MCP servers
for mcp in all_mcps:
if mcp.get("type") == "remote":
url = mcp.get("url", "")
headers = mcp.get("headers", {})
if url:
mcp_servers.append(
{
"name": mcp.get("name", "Remote MCP"),
"url": url,
"headers": headers,
}
)
# Process container-based MCP servers from the configuration
for mcp in all_mcps:
# We only need to connect to container-based MCPs
if mcp.get("type") in ["docker", "proxy"]:
mcp_name = mcp.get("name")
try:
# Get the container name for this MCP
container_name = f"mc_mcp_{mcp_name}"
container = None
# Try to find the container
try:
container = client.containers.get(container_name)
except docker.errors.NotFound:
console.print(
f"[yellow]Warning: Container for MCP '{mcp_name}' not found[/yellow]"
)
continue
if container and container.status == "running":
# Find all networks this MCP container is connected to
for network_name, network_info in (
container.attrs.get("NetworkSettings", {})
.get("Networks", {})
.items()
):
# Don't add default bridge network - it doesn't support DNS resolution
# Also avoid duplicate networks
if (
network_name != "bridge"
and network_name not in mcp_networks_to_connect
):
mcp_networks_to_connect.append(network_name)
# For proxy type, get the SSE port from the config
port = "8080" # Default MCP proxy SSE port
if mcp.get("type") == "proxy" and "proxy_options" in mcp:
port = str(
mcp.get("proxy_options", {}).get("sse_port", "8080")
)
# Add container-based MCP server URL using just the MCP name as the hostname
# This works because we join all networks and the MCP containers have aliases
mcp_servers.append(
{
"name": mcp_name,
"url": f"http://{mcp_name}:{port}",
"headers": {},
}
)
except Exception as e:
console.print(
f"[yellow]Warning: Error processing MCP '{mcp_name}': {str(e)}[/yellow]"
)
# Make sure we have at least one network to connect to
if not mcp_networks_to_connect:
# Create an MCP-specific network if none exists
network_name = "mc-mcp-network"
console.print("No MCP networks found, creating a default one")
try:
networks = client.networks.list(names=[network_name])
if not networks:
client.networks.create(network_name, driver="bridge")
mcp_networks_to_connect.append(network_name)
except Exception as e:
console.print(
f"[yellow]Warning: Could not create default network: {str(e)}[/yellow]"
)
# Pull the image if needed (silently)
try:
client.images.get("mcp/inspector")
except docker.errors.ImageNotFound:
client.images.pull("mcp/inspector")
try:
# Create a custom entrypoint to handle the localhost binding issue and auto-connect to MCP servers
script_content = """#!/bin/sh
# This script modifies the Express server to bind to all interfaces
# Try to find the CLI script
CLI_FILE=$(find /app -name "cli.js" | grep -v node_modules | head -1)
if [ -z "$CLI_FILE" ]; then
echo "Could not find CLI file. Trying common locations..."
for path in "/app/client/bin/cli.js" "/app/bin/cli.js" "./client/bin/cli.js" "./bin/cli.js"; do
if [ -f "$path" ]; then
CLI_FILE="$path"
break
fi
done
fi
if [ -z "$CLI_FILE" ]; then
echo "ERROR: Could not find the MCP Inspector CLI file."
exit 1
fi
echo "Found CLI file at: $CLI_FILE"
# Make a backup of the original file
cp "$CLI_FILE" "$CLI_FILE.bak"
# Modify the file to use 0.0.0.0 as the host
sed -i 's/app.listen(PORT/app.listen(PORT, "0.0.0.0"/g' "$CLI_FILE"
sed -i 's/server.listen(port/server.listen(port, "0.0.0.0"/g' "$CLI_FILE"
sed -i 's/listen(PORT/listen(PORT, "0.0.0.0"/g' "$CLI_FILE"
echo "Modified server to listen on all interfaces (0.0.0.0)"
# Start the MCP Inspector
echo "Starting MCP Inspector on all interfaces..."
exec npm start
"""
# Write the script to a temp file
script_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "mc_inspector_entrypoint.sh"
)
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
# Use the script as the entrypoint
# The entrypoint is directly specified in the container.run() call below
# Run the MCP Inspector container - use the first network initially
initial_network = (
mcp_networks_to_connect[0] if mcp_networks_to_connect else "bridge"
)
console.print(f"Starting Inspector on network: {initial_network}")
# Check if existing container with the same name exists, and remove it
try:
existing = client.containers.get("mc_mcp_inspector")
if existing.status == "running":
existing.stop(timeout=1)
existing.remove(force=True)
console.print("Removed existing MCP Inspector container")
except docker.errors.NotFound:
pass
except Exception as e:
console.print(
f"[yellow]Warning: Error removing existing container: {e}[/yellow]"
)
# Create network config with just the inspector alias for the initial network
network_config = {
initial_network: {
"aliases": [
"inspector"
] # Allow container to be reached as just "inspector"
}
}
# Log MCP servers that are in the initial network
initial_mcp_containers = []
for mcp in all_mcps:
if mcp.get("type") in ["docker", "proxy"]:
mcp_name = mcp.get("name")
container_name = f"mc_mcp_{mcp_name}"
try:
# Check if this container exists
mcp_container = client.containers.get(container_name)
# Check if it's in the initial network
if initial_network in mcp_container.attrs.get(
"NetworkSettings", {}
).get("Networks", {}):
initial_mcp_containers.append(mcp_name)
except Exception:
pass
if initial_mcp_containers:
console.print(
f"MCP servers in initial network: {', '.join(initial_mcp_containers)}"
)
container = client.containers.run(
image="mcp/inspector",
name="mc_mcp_inspector", # Use a fixed name
detach=True,
network=initial_network,
ports={
"5173/tcp": host_port, # Map container port 5173 to host port (frontend)
"3000/tcp": 3000, # Map container port 3000 to host port 3000 (backend)
},
environment={
"SERVER_PORT": "3000", # Tell the server to use port 3000 (default)
},
volumes={
script_path: {
"bind": "/entrypoint.sh",
"mode": "ro",
}
},
entrypoint="/entrypoint.sh",
labels={
"mc.mcp.inspector": "true",
"mc.managed": "true",
},
network_mode=None, # Don't use network_mode as we're using network with aliases
networking_config=client.api.create_networking_config(network_config),
)
# Connect to all additional MCP networks
if len(mcp_networks_to_connect) > 1:
# Get the networks the container is already connected to
container_networks = list(
container.attrs["NetworkSettings"]["Networks"].keys()
)
for network_name in mcp_networks_to_connect[
1:
]: # Skip the first one that we already connected to
# Skip if already connected to this network
if network_name in container_networks:
console.print(
f"Inspector already connected to network: {network_name}"
)
continue
try:
console.print(
f"Connecting Inspector to additional network: {network_name}"
)
network = client.networks.get(network_name)
# Get all MCP containers in this network
mcp_containers = []
# Find all MCP containers that are in this network
for mcp in all_mcps:
if mcp.get("type") in ["docker", "proxy"]:
mcp_name = mcp.get("name")
container_name = f"mc_mcp_{mcp_name}"
try:
# Check if this container exists
mcp_container = client.containers.get(
container_name
)
# Check if it's in the current network
if network_name in mcp_container.attrs.get(
"NetworkSettings", {}
).get("Networks", {}):
mcp_containers.append(mcp_name)
except Exception:
pass
# Connect the inspector with the inspector alias and the individual MCP server aliases
network.connect(container, aliases=["inspector"])
console.print(f" Added inspector to network {network_name}")
if mcp_containers:
console.print(
f" MCP servers in this network: {', '.join(mcp_containers)}"
)
except Exception as e:
console.print(
f"[yellow]Warning: Could not connect Inspector to network {network_name}: {str(e)}[/yellow]"
)
# Wait a moment for the container to start properly
time.sleep(1)
except Exception as e:
console.print(f"[red]Error running MCP Inspector: {e}[/red]")
# Try to clean up
try:
client.containers.get(container_name).remove(force=True)
except Exception:
pass
return
console.print("[bold]MCP Inspector is available at:[/bold]")
console.print(f"- Frontend: http://localhost:{host_port}")
console.print("- Backend API: http://localhost:3000")
if len(mcp_servers) > 0:
console.print(
f"[green]Auto-connected to {len(mcp_servers)} MCP servers[/green]"
)
else:
console.print(
"[yellow]Warning: No MCP servers found or started. The Inspector will run but won't have any servers to connect to.[/yellow]"
)
console.print(
"Start MCP servers using 'mc mcp start --all' and then restart the Inspector."
)
if not detach:
try:
console.print("[yellow]Press Ctrl+C to stop the MCP Inspector...[/yellow]")
for line in container.logs(stream=True):
console.print(line.decode().strip())
except KeyboardInterrupt:
with console.status("Stopping MCP Inspector..."):
container.stop()
container.remove(force=True)
console.print("[green]MCP Inspector stopped[/green]")
if __name__ == "__main__":
app()

View File

@@ -332,17 +332,21 @@ class ContainerManager:
)
else:
# For Docker/proxy MCP, set the connection details
# Use the container name as hostname for internal Docker DNS resolution
# Use both the container name and the short name for internal Docker DNS resolution
container_name = self.mcp_manager.get_mcp_container_name(
mcp_name
)
env_vars[f"MCP_{idx}_HOST"] = container_name
# Use the short name (mcp_name) as the primary hostname
env_vars[f"MCP_{idx}_HOST"] = mcp_name
# Default port is 8080 unless specified in status
port = next(
iter(mcp_status.get("ports", {}).values()), 8080
)
env_vars[f"MCP_{idx}_PORT"] = str(port)
env_vars[f"MCP_{idx}_URL"] = (
# Use the short name in the URL to take advantage of the network alias
env_vars[f"MCP_{idx}_URL"] = f"http://{mcp_name}:{port}/sse"
# For backward compatibility, also set the full container name URL
env_vars[f"MCP_{idx}_CONTAINER_URL"] = (
f"http://{container_name}:{port}/sse"
)
@@ -424,9 +428,11 @@ class ContainerManager:
network_name, driver="bridge"
)
# Connect the container to the network
network.connect(container)
print(f"Connected to network: {network_name}")
# Connect the container to the network with session name as an alias
network.connect(container, aliases=[session_name])
print(
f"Connected to network: {network_name} with alias: {session_name}"
)
except DockerException as e:
print(f"Error connecting to network {network_name}: {e}")
@@ -448,9 +454,11 @@ class ContainerManager:
network_name, driver="bridge"
)
# Connect the container to the network
network.connect(container)
print(f"Connected to network: {network_name}")
# Connect the container to the network with session name as an alias
network.connect(container, aliases=[session_name])
print(
f"Connected to network: {network_name} with alias: {session_name}"
)
except DockerException as e:
print(f"Error connecting to network {network_name}: {e}")

View File

@@ -0,0 +1,36 @@
#!/bin/sh
# This script modifies the Express server to bind to all interfaces
# Try to find the CLI script
CLI_FILE=$(find /app -name "cli.js" | grep -v node_modules | head -1)
if [ -z "$CLI_FILE" ]; then
echo "Could not find CLI file. Trying common locations..."
for path in "/app/client/bin/cli.js" "/app/bin/cli.js" "./client/bin/cli.js" "./bin/cli.js"; do
if [ -f "$path" ]; then
CLI_FILE="$path"
break
fi
done
fi
if [ -z "$CLI_FILE" ]; then
echo "ERROR: Could not find the MCP Inspector CLI file."
exit 1
fi
echo "Found CLI file at: $CLI_FILE"
# Make a backup of the original file
cp "$CLI_FILE" "$CLI_FILE.bak"
# Modify the file to use 0.0.0.0 as the host
sed -i 's/app.listen(PORT/app.listen(PORT, "0.0.0.0"/g' "$CLI_FILE"
sed -i 's/server.listen(port/server.listen(port, "0.0.0.0"/g' "$CLI_FILE"
sed -i 's/listen(PORT/listen(PORT, "0.0.0.0"/g' "$CLI_FILE"
echo "Modified server to listen on all interfaces (0.0.0.0)"
# Start the MCP Inspector
echo "Starting MCP Inspector on all interfaces..."
exec npm start

View File

@@ -114,8 +114,27 @@ class MCPManager:
command: str,
proxy_options: Dict[str, Any] = None,
env: Dict[str, str] = None,
host_port: Optional[int] = None,
) -> Dict[str, Any]:
"""Add a proxy-based MCP server."""
# 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,
@@ -124,6 +143,7 @@ class MCPManager:
command=command,
proxy_options=proxy_options or {},
env=env or {},
host_port=host_port,
)
# Add to the configuration
@@ -179,16 +199,45 @@ class MCPManager:
# Check if the container already exists
try:
container = self.client.containers.get(container_name)
# If it exists, start it if it's not running
if container.status != "running":
container.start()
# Check if we need to recreate the container due to port binding changes
needs_recreate = False
# Return the container status
return {
"container_id": container.id,
"status": "running",
"name": name,
}
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
@@ -221,16 +270,20 @@ class MCPManager:
command=mcp_config.get("command"),
name=container_name,
detach=True,
network=network_name,
network=None, # Start without network, we'll add it with aliases
environment=mcp_config.get("env", {}),
labels={
"mc.mcp": "true",
"mc.mcp.name": name,
"mc.mcp.type": "docker",
},
ports={
"8080/tcp": 8080, # Default SSE port
},
)
# Connect to the network with aliases
network = self.client.networks.get(network_name)
network.connect(container, aliases=[name])
logger.info(
f"Connected MCP server '{name}' to network {network_name} with alias '{name}'"
)
return {
@@ -250,7 +303,7 @@ echo "Starting MCP proxy with base image $MCP_BASE_IMAGE (command: $MCP_COMMAND)
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
@@ -270,7 +323,7 @@ while True:
sys.stdout.flush()
time.sleep(1)
EOF
exec mcp-proxy \
--sse-port "$SSE_PORT" \
--sse-host "$SSE_HOST" \
@@ -324,7 +377,7 @@ RUN (apt-get update && apt-get install -y docker.io) || \\
# Set environment variables for the proxy
ENV MCP_BASE_IMAGE={mcp_config["base_image"]}
ENV MCP_COMMAND={mcp_config.get("command", "")}
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", "*")}
@@ -360,12 +413,21 @@ ENTRYPOINT ["/entrypoint.sh"]
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=network_name,
network=None, # Start without network, we'll add it with aliases
volumes={
"/var/run/docker.sock": {
"bind": "/var/run/docker.sock",
@@ -377,11 +439,14 @@ ENTRYPOINT ["/entrypoint.sh"]
"mc.mcp.name": name,
"mc.mcp.type": "proxy",
},
ports={
f"{mcp_config['proxy_options'].get('sse_port', 8080)}/tcp": mcp_config[
"proxy_options"
].get("sse_port", 8080),
},
ports=port_bindings, # Bind the SSE port to the host if configured
)
# Connect to the network with aliases
network = self.client.networks.get(network_name)
network.connect(container, aliases=[name])
logger.info(
f"Connected MCP server '{name}' to network {network_name} with alias '{name}'"
)
return {
@@ -413,8 +478,13 @@ ENTRYPOINT ["/entrypoint.sh"]
# Try to get and stop the container
try:
container = self.client.containers.get(container_name)
container.stop(timeout=10)
return True
# Only stop if it's running
if container.status == "running":
container.stop(timeout=10)
return True
else:
# Container exists but is not running
return False
except NotFound:
# Container doesn't exist
return False
@@ -493,8 +563,17 @@ ENTRYPOINT ["/entrypoint.sh"]
# Get container details
container_info = container.attrs
# Extract ports
# 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"]
@@ -503,6 +582,7 @@ ENTRYPOINT ["/entrypoint.sh"]
"Ports"
].items():
if mappings:
# Port is bound to host
ports[port] = int(mappings[0]["HostPort"])
return {
@@ -574,8 +654,17 @@ ENTRYPOINT ["/entrypoint.sh"]
# Extract labels
labels = container_info["Config"]["Labels"]
# Extract ports
# 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"]
@@ -584,6 +673,7 @@ ENTRYPOINT ["/entrypoint.sh"]
"Ports"
].items():
if mappings:
# Port is bound to host
ports[port] = int(mappings[0]["HostPort"])
# Determine status

View File

@@ -78,6 +78,7 @@ class ProxyMCP(BaseModel):
command: str
proxy_options: Dict[str, Any] = Field(default_factory=dict)
env: Dict[str, str] = Field(default_factory=dict)
host_port: Optional[int] = None # External port to bind the SSE port to on the host
MCP = Union[RemoteMCP, DockerMCP, ProxyMCP]
@@ -88,7 +89,7 @@ class MCPContainer(BaseModel):
container_id: str
status: MCPStatus
image: str
ports: Dict[str, int] = Field(default_factory=dict)
ports: Dict[str, Optional[int]] = Field(default_factory=dict)
created_at: str
type: str

View File

@@ -0,0 +1,82 @@
"""
Integration test for MCP port binding.
"""
import time
import uuid
from conftest import requires_docker
from mcontainer.mcp import MCPManager
@requires_docker
def test_mcp_port_binding():
"""Test that MCP containers don't bind to host ports."""
mcp_manager = MCPManager()
# Add a proxy MCP
mcp_name = f"test-mcp-{uuid.uuid4().hex[:8]}"
mcp_name2 = None
try:
# Let's check if host port binding was removed
mcps_before = len(mcp_manager.list_mcp_containers())
# Use alpine image for a simple test
mcp_manager.add_docker_mcp(
name=mcp_name,
image="alpine:latest",
command="sleep 60", # Keep container running for the test
env={"TEST": "test"},
)
# Start the MCP
result = mcp_manager.start_mcp(mcp_name)
print(f"Start result: {result}")
# Give container time to start
time.sleep(2)
# Start another MCP to verify we can run multiple instances
mcp_name2 = f"test-mcp2-{uuid.uuid4().hex[:8]}"
mcp_manager.add_docker_mcp(
name=mcp_name2,
image="alpine:latest",
command="sleep 60", # Keep container running for the test
env={"TEST": "test2"},
)
# Start the second MCP
result2 = mcp_manager.start_mcp(mcp_name2)
print(f"Start result 2: {result2}")
# Give container time to start
time.sleep(2)
# Check how many containers we have now
mcps_after = len(mcp_manager.list_mcp_containers())
# We should have two more containers than before
assert mcps_after >= mcps_before + 2, "Not all MCP containers were created"
# Get container details and verify no host port bindings
all_mcps = mcp_manager.list_mcp_containers()
print(f"All MCPs: {all_mcps}")
# Test successful - we were able to start multiple MCPs without port conflicts
finally:
# Clean up
try:
if mcp_name:
mcp_manager.stop_mcp(mcp_name)
mcp_manager.remove_mcp(mcp_name)
except Exception as e:
print(f"Error cleaning up {mcp_name}: {e}")
try:
if mcp_name2:
mcp_manager.stop_mcp(mcp_name2)
mcp_manager.remove_mcp(mcp_name2)
except Exception as e:
print(f"Error cleaning up {mcp_name2}: {e}")