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 import os
from typing import List, Optional from typing import List, Optional
import typer import typer
@@ -784,6 +788,7 @@ def list_mcps() -> None:
table.add_column("Name") table.add_column("Name")
table.add_column("Type") table.add_column("Type")
table.add_column("Status") table.add_column("Status")
table.add_column("Ports")
table.add_column("Details") table.add_column("Details")
# Check status of each MCP # Check status of each MCP
@@ -804,6 +809,24 @@ def list_mcps() -> None:
"failed": "red", "failed": "red",
}.get(status, "white") }.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 # Different details based on MCP type
if mcp_type == "remote": if mcp_type == "remote":
details = mcp.get("url", "") details = mcp.get("url", "")
@@ -820,6 +843,7 @@ def list_mcps() -> None:
name, name,
mcp_type, mcp_type,
f"[{status_color}]{status}[/{status_color}]", f"[{status_color}]{status}[/{status_color}]",
ports_info,
details, details,
) )
except Exception as e: except Exception as e:
@@ -827,6 +851,7 @@ def list_mcps() -> None:
name, name,
mcp_type, mcp_type,
"[red]error[/red]", "[red]error[/red]",
"", # Empty ports column for error
str(e), 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')}" f"[bold]Container ID:[/bold] {status_info.get('container_id')}"
) )
if status_info.get("ports"): 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(): 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"): if status_info.get("created"):
console.print(f"[bold]Created:[/bold] {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( console.print(
f"[bold]Proxy Image:[/bold] {mcp_config.get('proxy_image')}" 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]") console.print("[bold]Proxy Options:[/bold]")
for key, value in mcp_config.get("proxy_options", {}).items(): for key, value in mcp_config.get("proxy_options", {}).items():
console.print(f" {key}: {value}") 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") @mcp_app.command("start")
def start_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None: def start_mcp(
"""Start an MCP server""" name: Optional[str] = typer.Argument(None, help="MCP server name"),
try: all_servers: bool = typer.Option(False, "--all", help="Start all MCP servers"),
with console.status(f"Starting MCP server '{name}'..."): ) -> None:
result = mcp_manager.start_mcp(name) """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": if not mcps:
console.print(f"[green]Started MCP server '{name}'[/green]") console.print("[yellow]No MCP servers configured[/yellow]")
elif result.get("status") == "not_applicable": 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( 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: if remote_count > 0:
console.print(f"MCP server '{name}' status: {result.get('status')}") 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: # Otherwise start a specific server
console.print(f"[red]Error starting MCP server: {e}[/red]") 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") @mcp_app.command("stop")
def stop_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None: def stop_mcp(
"""Stop an MCP server""" name: Optional[str] = typer.Argument(None, help="MCP server name"),
try: all_servers: bool = typer.Option(False, "--all", help="Stop all MCP servers"),
with console.status(f"Stopping MCP server '{name}'..."): ) -> None:
result = mcp_manager.stop_mcp(name) """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: if not mcps:
console.print(f"[green]Stopped MCP server '{name}'[/green]") console.print("[yellow]No MCP servers configured[/yellow]")
else: return
console.print(f"[yellow]MCP server '{name}' was not running[/yellow]")
except Exception as e: # Count of successfully stopped servers
console.print(f"[red]Error stopping MCP server: {e}[/red]") 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") @mcp_app.command("restart")
def restart_mcp(name: str = typer.Argument(..., help="MCP server name")) -> None: def restart_mcp(
"""Restart an MCP server""" name: Optional[str] = typer.Argument(None, help="MCP server name"),
try: all_servers: bool = typer.Option(False, "--all", help="Restart all MCP servers"),
with console.status(f"Restarting MCP server '{name}'..."): ) -> None:
result = mcp_manager.restart_mcp(name) """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": if not mcps:
console.print(f"[green]Restarted MCP server '{name}'[/green]") console.print("[yellow]No MCP servers configured[/yellow]")
elif result.get("status") == "not_applicable": 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( 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: if remote_count > 0:
console.print(f"MCP server '{name}' status: {result.get('status')}") 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: # Otherwise restart a specific server
console.print(f"[red]Error restarting MCP server: {e}[/red]") 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") @mcp_app.command("logs")
@@ -1004,11 +1228,19 @@ def add_mcp(
command: str = typer.Option( command: str = typer.Option(
"", "--command", "-c", help="Command to run in the container" "", "--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"), sse_host: str = typer.Option("0.0.0.0", "--sse-host", help="Host for SSE server"),
allow_origin: str = typer.Option( allow_origin: str = typer.Option(
"*", "--allow-origin", help="CORS allow-origin header" "*", "--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: List[str] = typer.Option(
[], "--env", "-e", help="Environment variables (format: KEY=VALUE)" [], "--env", "-e", help="Environment variables (format: KEY=VALUE)"
), ),
@@ -1034,11 +1266,24 @@ def add_mcp(
try: try:
with console.status(f"Adding MCP server '{name}'..."): with console.status(f"Adding MCP server '{name}'..."):
mcp_manager.add_proxy_mcp( result = mcp_manager.add_proxy_mcp(
name, base_image, proxy_image, command, proxy_options, environment 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]") 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: except Exception as e:
console.print(f"[red]Error adding MCP server: {e}[/red]") 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]") 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__": if __name__ == "__main__":
app() app()

View File

@@ -332,17 +332,21 @@ class ContainerManager:
) )
else: else:
# For Docker/proxy MCP, set the connection details # 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( container_name = self.mcp_manager.get_mcp_container_name(
mcp_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 # Default port is 8080 unless specified in status
port = next( port = next(
iter(mcp_status.get("ports", {}).values()), 8080 iter(mcp_status.get("ports", {}).values()), 8080
) )
env_vars[f"MCP_{idx}_PORT"] = str(port) 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" f"http://{container_name}:{port}/sse"
) )
@@ -424,9 +428,11 @@ class ContainerManager:
network_name, driver="bridge" network_name, driver="bridge"
) )
# Connect the container to the network # Connect the container to the network with session name as an alias
network.connect(container) network.connect(container, aliases=[session_name])
print(f"Connected to network: {network_name}") print(
f"Connected to network: {network_name} with alias: {session_name}"
)
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}")
@@ -448,9 +454,11 @@ class ContainerManager:
network_name, driver="bridge" network_name, driver="bridge"
) )
# Connect the container to the network # Connect the container to the network with session name as an alias
network.connect(container) network.connect(container, aliases=[session_name])
print(f"Connected to network: {network_name}") print(
f"Connected to network: {network_name} with alias: {session_name}"
)
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}")

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, command: str,
proxy_options: Dict[str, Any] = None, proxy_options: Dict[str, Any] = None,
env: Dict[str, str] = None, env: Dict[str, str] = None,
host_port: Optional[int] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Add a proxy-based MCP server.""" """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 # Create the Proxy MCP configuration
proxy_mcp = ProxyMCP( proxy_mcp = ProxyMCP(
name=name, name=name,
@@ -124,6 +143,7 @@ class MCPManager:
command=command, command=command,
proxy_options=proxy_options or {}, proxy_options=proxy_options or {},
env=env or {}, env=env or {},
host_port=host_port,
) )
# Add to the configuration # Add to the configuration
@@ -179,16 +199,45 @@ class MCPManager:
# Check if the container already exists # Check if the container already exists
try: try:
container = self.client.containers.get(container_name) container = self.client.containers.get(container_name)
# If it exists, start it if it's not running # Check if we need to recreate the container due to port binding changes
if container.status != "running": needs_recreate = False
container.start()
# Return the container status if mcp_config.get("type") == "proxy" and mcp_config.get("host_port"):
return { # Get the current container port bindings
"container_id": container.id, port_bindings = container.attrs.get("HostConfig", {}).get(
"status": "running", "PortBindings", {}
"name": name, )
} 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: except NotFound:
# Container doesn't exist, we need to create it # Container doesn't exist, we need to create it
pass pass
@@ -221,16 +270,20 @@ class MCPManager:
command=mcp_config.get("command"), command=mcp_config.get("command"),
name=container_name, name=container_name,
detach=True, detach=True,
network=network_name, network=None, # Start without network, we'll add it with aliases
environment=mcp_config.get("env", {}), environment=mcp_config.get("env", {}),
labels={ labels={
"mc.mcp": "true", "mc.mcp": "true",
"mc.mcp.name": name, "mc.mcp.name": name,
"mc.mcp.type": "docker", "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 { 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 if [ ! -S /var/run/docker.sock ]; then
echo "ERROR: Docker socket not available. Cannot run base MCP image." echo "ERROR: Docker socket not available. Cannot run base MCP image."
echo "Make sure the Docker socket is mounted from the host." echo "Make sure the Docker socket is mounted from the host."
# Create a minimal fallback server for testing # Create a minimal fallback server for testing
cat > /tmp/fallback_server.py << 'EOF' cat > /tmp/fallback_server.py << 'EOF'
import json, sys, time import json, sys, time
@@ -270,7 +323,7 @@ while True:
sys.stdout.flush() sys.stdout.flush()
time.sleep(1) time.sleep(1)
EOF EOF
exec mcp-proxy \ exec mcp-proxy \
--sse-port "$SSE_PORT" \ --sse-port "$SSE_PORT" \
--sse-host "$SSE_HOST" \ --sse-host "$SSE_HOST" \
@@ -324,7 +377,7 @@ RUN (apt-get update && apt-get install -y docker.io) || \\
# Set environment variables for the proxy # Set environment variables for the proxy
ENV MCP_BASE_IMAGE={mcp_config["base_image"]} 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_PORT={mcp_config["proxy_options"].get("sse_port", 8080)}
ENV SSE_HOST={mcp_config["proxy_options"].get("sse_host", "0.0.0.0")} 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 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', '')}" 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 # Create and start the container
container = self.client.containers.run( container = self.client.containers.run(
image=custom_image_name, image=custom_image_name,
name=container_name, name=container_name,
detach=True, detach=True,
network=network_name, network=None, # Start without network, we'll add it with aliases
volumes={ volumes={
"/var/run/docker.sock": { "/var/run/docker.sock": {
"bind": "/var/run/docker.sock", "bind": "/var/run/docker.sock",
@@ -377,11 +439,14 @@ ENTRYPOINT ["/entrypoint.sh"]
"mc.mcp.name": name, "mc.mcp.name": name,
"mc.mcp.type": "proxy", "mc.mcp.type": "proxy",
}, },
ports={ ports=port_bindings, # Bind the SSE port to the host if configured
f"{mcp_config['proxy_options'].get('sse_port', 8080)}/tcp": mcp_config[ )
"proxy_options"
].get("sse_port", 8080), # 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 { return {
@@ -413,8 +478,13 @@ ENTRYPOINT ["/entrypoint.sh"]
# Try to get and stop the container # Try to get and stop the container
try: try:
container = self.client.containers.get(container_name) container = self.client.containers.get(container_name)
container.stop(timeout=10) # Only stop if it's running
return True if container.status == "running":
container.stop(timeout=10)
return True
else:
# Container exists but is not running
return False
except NotFound: except NotFound:
# Container doesn't exist # Container doesn't exist
return False return False
@@ -493,8 +563,17 @@ ENTRYPOINT ["/entrypoint.sh"]
# Get container details # Get container details
container_info = container.attrs container_info = container.attrs
# Extract ports # Extract exposed ports from config
ports = {} 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 ( if (
"NetworkSettings" in container_info "NetworkSettings" in container_info
and "Ports" in container_info["NetworkSettings"] and "Ports" in container_info["NetworkSettings"]
@@ -503,6 +582,7 @@ ENTRYPOINT ["/entrypoint.sh"]
"Ports" "Ports"
].items(): ].items():
if mappings: if mappings:
# Port is bound to host
ports[port] = int(mappings[0]["HostPort"]) ports[port] = int(mappings[0]["HostPort"])
return { return {
@@ -574,8 +654,17 @@ ENTRYPOINT ["/entrypoint.sh"]
# Extract labels # Extract labels
labels = container_info["Config"]["Labels"] labels = container_info["Config"]["Labels"]
# Extract ports # Extract exposed ports from config
ports = {} 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 ( if (
"NetworkSettings" in container_info "NetworkSettings" in container_info
and "Ports" in container_info["NetworkSettings"] and "Ports" in container_info["NetworkSettings"]
@@ -584,6 +673,7 @@ ENTRYPOINT ["/entrypoint.sh"]
"Ports" "Ports"
].items(): ].items():
if mappings: if mappings:
# Port is bound to host
ports[port] = int(mappings[0]["HostPort"]) ports[port] = int(mappings[0]["HostPort"])
# Determine status # Determine status

View File

@@ -78,6 +78,7 @@ class ProxyMCP(BaseModel):
command: str command: str
proxy_options: Dict[str, Any] = Field(default_factory=dict) proxy_options: Dict[str, Any] = Field(default_factory=dict)
env: Dict[str, str] = 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] MCP = Union[RemoteMCP, DockerMCP, ProxyMCP]
@@ -88,7 +89,7 @@ class MCPContainer(BaseModel):
container_id: str container_id: str
status: MCPStatus status: MCPStatus
image: str image: str
ports: Dict[str, int] = Field(default_factory=dict) ports: Dict[str, Optional[int]] = Field(default_factory=dict)
created_at: str created_at: str
type: 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}")