Skip to content

feat: add router command & auto reload profile configure #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
"ruamel-yaml>=0.18.10",
"watchfiles>=1.0.4",
"duckdb>=1.2.2",
"psutil>=7.0.0",
]

[project.urls]
Expand Down
3 changes: 3 additions & 0 deletions src/mcpm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
search,
stash,
transfer,
router,
)

console = Console()
Expand Down Expand Up @@ -133,6 +134,7 @@ def main(ctx, help_flag):
commands_table.add_row(" [cyan]copy/cp[/]", "Copy a server from one client/profile to another.")
commands_table.add_row(" [cyan]activate[/]", "Activate a profile.")
commands_table.add_row(" [cyan]deactivate[/]", "Deactivate a profile.")
commands_table.add_row(" [cyan]router[/]", "Manage MCP router service.")
console.print(commands_table)

# Additional helpful information
Expand Down Expand Up @@ -161,6 +163,7 @@ def main(ctx, help_flag):
main.add_command(transfer.copy, name="cp")
main.add_command(profile.activate)
main.add_command(profile.deactivate)
main.add_command(router.router, name="router")

if __name__ == "__main__":
main()
15 changes: 2 additions & 13 deletions src/mcpm/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,8 @@
MCPM commands package
"""

__all__ = [
"add",
"client",
"inspector",
"list",
"pop",
"profile",
"remove",
"search",
"stash",
"transfer",
]
__all__ = ["add", "client", "inspector", "list", "pop", "profile", "remove", "search", "stash", "transfer", "router"]

# All command modules
from . import client, inspector, list, profile, search
from . import client, inspector, list, profile, router, search
from .server_operations import add, pop, remove, stash, transfer
185 changes: 185 additions & 0 deletions src/mcpm/commands/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""
Router command for managing the MCPRouter daemon process
"""

import logging
import os
import signal
import subprocess
import sys

import click
import psutil
from rich.console import Console

from mcpm.utils.platform import get_log_directory, get_pid_directory

logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
console = Console()

APP_SUPPORT_DIR = get_pid_directory("mcpm")
APP_SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE = APP_SUPPORT_DIR / "router.pid"

LOG_DIR = get_log_directory("mcpm")
LOG_DIR.mkdir(parents=True, exist_ok=True)


def is_process_running(pid):
"""check if the process is running"""
try:
return psutil.pid_exists(pid)
except Exception:
return False


def read_pid_file():
"""read the pid file and return the process id, if the file does not exist or the process is not running, return None"""
if not PID_FILE.exists():
return None

try:
pid = int(PID_FILE.read_text().strip())
if is_process_running(pid):
return pid
else:
# if the process is not running, delete the pid file
remove_pid_file()
return None
except (ValueError, IOError) as e:
logger.error(f"Error reading PID file: {e}")
return None


def write_pid_file(pid):
"""write the process id to the pid file"""
try:
PID_FILE.write_text(str(pid))
logger.info(f"PID {pid} written to {PID_FILE}")
except IOError as e:
logger.error(f"Error writing PID file: {e}")
sys.exit(1)


def remove_pid_file():
"""remove the pid file"""
try:
PID_FILE.unlink(missing_ok=True)
except IOError as e:
logger.error(f"Error removing PID file: {e}")


@click.group(name="router")
def router():
"""Manage MCP router service."""
pass


@router.command(name="on")
@click.option("--host", type=str, default="0.0.0.0", help="Host to bind the SSE server to")
@click.option("--port", type=int, default=8080, help="Port to bind the SSE server to")
@click.option("--cors", type=str, help="Comma-separated list of allowed origins for CORS")
def start_router(host, port, cors):
"""Start MCPRouter as a daemon process.

Example:
mcpm router on
mcpm router on --port 8888
mcpm router on --host 0.0.0.0 --port 9000
"""
# check if there is a router already running
existing_pid = read_pid_file()
if existing_pid:
console.print(f"[bold red]Error:[/] MCPRouter is already running (PID: {existing_pid})")
console.print("Use 'mcpm router off' to stop the running instance.")
return

# prepare environment variables
env = os.environ.copy()
if cors:
env["MCPM_ROUTER_CORS"] = cors

# prepare uvicorn command
uvicorn_cmd = [
sys.executable,
"-m",
"uvicorn",
"mcpm.router.app:app",
"--host",
host,
"--port",
str(port),
"--timeout-graceful-shutdown",
"5",
]

# start process
try:
# create log file
log_file = LOG_DIR / "router_access.log"

# open log file, prepare to redirect stdout and stderr
with open(log_file, "a") as log:
# use subprocess.Popen to start uvicorn
process = subprocess.Popen(
uvicorn_cmd,
stdout=log,
stderr=log,
env=env,
start_new_session=True, # create new session, so the process won't be affected by terminal closing
)

# record PID
pid = process.pid
write_pid_file(pid)

console.print(f"[bold green]MCPRouter started[/] at http://{host}:{port} (PID: {pid})")
console.print(f"Log file: {log_file}")
console.print("Use 'mcpm router off' to stop the router.")

except Exception as e:
console.print(f"[bold red]Error:[/] Failed to start MCPRouter: {e}")


@router.command(name="off")
def stop_router():
"""Stop the running MCPRouter daemon process.

Example:
mcpm router off
"""
# check if there is a router already running
pid = read_pid_file()
if not pid:
console.print("[yellow]MCPRouter is not running.[/]")
return

# send termination signal
try:
os.kill(pid, signal.SIGTERM)
console.print(f"[bold green]MCPRouter stopped (PID: {pid})[/]")

# delete PID file
remove_pid_file()
except OSError as e:
console.print(f"[bold red]Error:[/] Failed to stop MCPRouter: {e}")

# if process does not exist, clean up PID file
if e.errno == 3: # "No such process"
console.print("[yellow]Process does not exist, cleaning up PID file...[/]")
remove_pid_file()


@router.command(name="status")
def router_status():
"""Check the status of the MCPRouter daemon process.

Example:
mcpm router status
"""
pid = read_pid_file()
if pid:
console.print(f"[bold green]MCPRouter is running[/] (PID: {pid})")
else:
console.print("[yellow]MCPRouter is not running.[/]")
3 changes: 3 additions & 0 deletions src/mcpm/profile/profile_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,6 @@ def remove_server(self, profile_name: str, server_name: str) -> bool:
self._save_profiles()
return True
return False

def reload(self):
self._profiles = self._load_profiles()
71 changes: 71 additions & 0 deletions src/mcpm/router/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.routing import Mount, Route

from mcpm.router.router import MCPRouter
from mcpm.router.transport import RouterSseTransport
from mcpm.utils.platform import get_log_directory

LOG_DIR = get_log_directory("mcpm")
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / "router.log"
CORS_ENABLED = os.environ.get("MCPM_ROUTER_CORS")

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()],
)
logger = logging.getLogger("mcpm.router.daemon")

router = MCPRouter(reload_server=True)
sse = RouterSseTransport("/messages/")


async def handle_sse(request: Request) -> None:
async with sse.connect_sse(
request.scope,
request.receive,
request._send, # noqa: SLF001
) as (read_stream, write_stream):
await router.aggregated_server.run(
read_stream,
write_stream,
router.aggregated_server.initialization_options, # type: ignore
)


@asynccontextmanager
async def lifespan(app):
logger.info("Starting MCPRouter...")
await router.initialize_router()

yield

logger.info("Shutting down MCPRouter...")
await router.shutdown()


middlewares = []
if CORS_ENABLED:
allow_origins = os.environ.get("MCPM_ROUTER_CORS", "").split(",")
middlewares.append(
Middleware(CORSMiddleware, allow_origins=allow_origins, allow_methods=["*"], allow_headers=["*"])
)

app = Starlette(
debug=False,
middleware=middlewares,
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message),
],
lifespan=lifespan,
)
2 changes: 1 addition & 1 deletion src/mcpm/router/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async def main(host: str, port: int, allow_origins: List[str] = None):
port: Port to bind the SSE server to
allow_origins: List of allowed origins for CORS
"""
router = MCPRouter()
router = MCPRouter(reload_server=True)

logger.info(f"Starting MCPRouter - will expose SSE server on http://{host}:{port}")

Expand Down
Loading