Skip to content

Commit 124cb93

Browse files
authored
feat: add router command & auto reload profile configure (#43)
* Add hot reload support for profile config and improve logging * feat: add router command for managing MCP server aggregation daemon * Add cross-platform support for log and PID file directories --------- Co-authored-by: calmini <[email protected]>
1 parent f14c59a commit 124cb93

File tree

12 files changed

+426
-44
lines changed

12 files changed

+426
-44
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies = [
2828
"ruamel-yaml>=0.18.10",
2929
"watchfiles>=1.0.4",
3030
"duckdb>=1.2.2",
31+
"psutil>=7.0.0",
3132
]
3233

3334
[project.urls]

src/mcpm/cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
search,
2121
stash,
2222
transfer,
23+
router,
2324
)
2425

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

138140
# Additional helpful information
@@ -161,6 +163,7 @@ def main(ctx, help_flag):
161163
main.add_command(transfer.copy, name="cp")
162164
main.add_command(profile.activate)
163165
main.add_command(profile.deactivate)
166+
main.add_command(router.router, name="router")
164167

165168
if __name__ == "__main__":
166169
main()

src/mcpm/commands/__init__.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,8 @@
22
MCPM commands package
33
"""
44

5-
__all__ = [
6-
"add",
7-
"client",
8-
"inspector",
9-
"list",
10-
"pop",
11-
"profile",
12-
"remove",
13-
"search",
14-
"stash",
15-
"transfer",
16-
]
5+
__all__ = ["add", "client", "inspector", "list", "pop", "profile", "remove", "search", "stash", "transfer", "router"]
176

187
# All command modules
19-
from . import client, inspector, list, profile, search
8+
from . import client, inspector, list, profile, router, search
209
from .server_operations import add, pop, remove, stash, transfer

src/mcpm/commands/router.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
Router command for managing the MCPRouter daemon process
3+
"""
4+
5+
import logging
6+
import os
7+
import signal
8+
import subprocess
9+
import sys
10+
11+
import click
12+
import psutil
13+
from rich.console import Console
14+
15+
from mcpm.utils.platform import get_log_directory, get_pid_directory
16+
17+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
18+
logger = logging.getLogger(__name__)
19+
console = Console()
20+
21+
APP_SUPPORT_DIR = get_pid_directory("mcpm")
22+
APP_SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
23+
PID_FILE = APP_SUPPORT_DIR / "router.pid"
24+
25+
LOG_DIR = get_log_directory("mcpm")
26+
LOG_DIR.mkdir(parents=True, exist_ok=True)
27+
28+
29+
def is_process_running(pid):
30+
"""check if the process is running"""
31+
try:
32+
return psutil.pid_exists(pid)
33+
except Exception:
34+
return False
35+
36+
37+
def read_pid_file():
38+
"""read the pid file and return the process id, if the file does not exist or the process is not running, return None"""
39+
if not PID_FILE.exists():
40+
return None
41+
42+
try:
43+
pid = int(PID_FILE.read_text().strip())
44+
if is_process_running(pid):
45+
return pid
46+
else:
47+
# if the process is not running, delete the pid file
48+
remove_pid_file()
49+
return None
50+
except (ValueError, IOError) as e:
51+
logger.error(f"Error reading PID file: {e}")
52+
return None
53+
54+
55+
def write_pid_file(pid):
56+
"""write the process id to the pid file"""
57+
try:
58+
PID_FILE.write_text(str(pid))
59+
logger.info(f"PID {pid} written to {PID_FILE}")
60+
except IOError as e:
61+
logger.error(f"Error writing PID file: {e}")
62+
sys.exit(1)
63+
64+
65+
def remove_pid_file():
66+
"""remove the pid file"""
67+
try:
68+
PID_FILE.unlink(missing_ok=True)
69+
except IOError as e:
70+
logger.error(f"Error removing PID file: {e}")
71+
72+
73+
@click.group(name="router")
74+
def router():
75+
"""Manage MCP router service."""
76+
pass
77+
78+
79+
@router.command(name="on")
80+
@click.option("--host", type=str, default="0.0.0.0", help="Host to bind the SSE server to")
81+
@click.option("--port", type=int, default=8080, help="Port to bind the SSE server to")
82+
@click.option("--cors", type=str, help="Comma-separated list of allowed origins for CORS")
83+
def start_router(host, port, cors):
84+
"""Start MCPRouter as a daemon process.
85+
86+
Example:
87+
mcpm router on
88+
mcpm router on --port 8888
89+
mcpm router on --host 0.0.0.0 --port 9000
90+
"""
91+
# check if there is a router already running
92+
existing_pid = read_pid_file()
93+
if existing_pid:
94+
console.print(f"[bold red]Error:[/] MCPRouter is already running (PID: {existing_pid})")
95+
console.print("Use 'mcpm router off' to stop the running instance.")
96+
return
97+
98+
# prepare environment variables
99+
env = os.environ.copy()
100+
if cors:
101+
env["MCPM_ROUTER_CORS"] = cors
102+
103+
# prepare uvicorn command
104+
uvicorn_cmd = [
105+
sys.executable,
106+
"-m",
107+
"uvicorn",
108+
"mcpm.router.app:app",
109+
"--host",
110+
host,
111+
"--port",
112+
str(port),
113+
"--timeout-graceful-shutdown",
114+
"5",
115+
]
116+
117+
# start process
118+
try:
119+
# create log file
120+
log_file = LOG_DIR / "router_access.log"
121+
122+
# open log file, prepare to redirect stdout and stderr
123+
with open(log_file, "a") as log:
124+
# use subprocess.Popen to start uvicorn
125+
process = subprocess.Popen(
126+
uvicorn_cmd,
127+
stdout=log,
128+
stderr=log,
129+
env=env,
130+
start_new_session=True, # create new session, so the process won't be affected by terminal closing
131+
)
132+
133+
# record PID
134+
pid = process.pid
135+
write_pid_file(pid)
136+
137+
console.print(f"[bold green]MCPRouter started[/] at http://{host}:{port} (PID: {pid})")
138+
console.print(f"Log file: {log_file}")
139+
console.print("Use 'mcpm router off' to stop the router.")
140+
141+
except Exception as e:
142+
console.print(f"[bold red]Error:[/] Failed to start MCPRouter: {e}")
143+
144+
145+
@router.command(name="off")
146+
def stop_router():
147+
"""Stop the running MCPRouter daemon process.
148+
149+
Example:
150+
mcpm router off
151+
"""
152+
# check if there is a router already running
153+
pid = read_pid_file()
154+
if not pid:
155+
console.print("[yellow]MCPRouter is not running.[/]")
156+
return
157+
158+
# send termination signal
159+
try:
160+
os.kill(pid, signal.SIGTERM)
161+
console.print(f"[bold green]MCPRouter stopped (PID: {pid})[/]")
162+
163+
# delete PID file
164+
remove_pid_file()
165+
except OSError as e:
166+
console.print(f"[bold red]Error:[/] Failed to stop MCPRouter: {e}")
167+
168+
# if process does not exist, clean up PID file
169+
if e.errno == 3: # "No such process"
170+
console.print("[yellow]Process does not exist, cleaning up PID file...[/]")
171+
remove_pid_file()
172+
173+
174+
@router.command(name="status")
175+
def router_status():
176+
"""Check the status of the MCPRouter daemon process.
177+
178+
Example:
179+
mcpm router status
180+
"""
181+
pid = read_pid_file()
182+
if pid:
183+
console.print(f"[bold green]MCPRouter is running[/] (PID: {pid})")
184+
else:
185+
console.print("[yellow]MCPRouter is not running.[/]")

src/mcpm/profile/profile_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ def remove_server(self, profile_name: str, server_name: str) -> bool:
8787
self._save_profiles()
8888
return True
8989
return False
90+
91+
def reload(self):
92+
self._profiles = self._load_profiles()

src/mcpm/router/app.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import logging
2+
import os
3+
from contextlib import asynccontextmanager
4+
from pathlib import Path
5+
6+
from starlette.applications import Starlette
7+
from starlette.middleware import Middleware
8+
from starlette.middleware.cors import CORSMiddleware
9+
from starlette.requests import Request
10+
from starlette.routing import Mount, Route
11+
12+
from mcpm.router.router import MCPRouter
13+
from mcpm.router.transport import RouterSseTransport
14+
from mcpm.utils.platform import get_log_directory
15+
16+
LOG_DIR = get_log_directory("mcpm")
17+
LOG_DIR.mkdir(parents=True, exist_ok=True)
18+
LOG_FILE = LOG_DIR / "router.log"
19+
CORS_ENABLED = os.environ.get("MCPM_ROUTER_CORS")
20+
21+
logging.basicConfig(
22+
level=logging.INFO,
23+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
24+
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()],
25+
)
26+
logger = logging.getLogger("mcpm.router.daemon")
27+
28+
router = MCPRouter(reload_server=True)
29+
sse = RouterSseTransport("/messages/")
30+
31+
32+
async def handle_sse(request: Request) -> None:
33+
async with sse.connect_sse(
34+
request.scope,
35+
request.receive,
36+
request._send, # noqa: SLF001
37+
) as (read_stream, write_stream):
38+
await router.aggregated_server.run(
39+
read_stream,
40+
write_stream,
41+
router.aggregated_server.initialization_options, # type: ignore
42+
)
43+
44+
45+
@asynccontextmanager
46+
async def lifespan(app):
47+
logger.info("Starting MCPRouter...")
48+
await router.initialize_router()
49+
50+
yield
51+
52+
logger.info("Shutting down MCPRouter...")
53+
await router.shutdown()
54+
55+
56+
middlewares = []
57+
if CORS_ENABLED:
58+
allow_origins = os.environ.get("MCPM_ROUTER_CORS", "").split(",")
59+
middlewares.append(
60+
Middleware(CORSMiddleware, allow_origins=allow_origins, allow_methods=["*"], allow_headers=["*"])
61+
)
62+
63+
app = Starlette(
64+
debug=False,
65+
middleware=middlewares,
66+
routes=[
67+
Route("/sse", endpoint=handle_sse),
68+
Mount("/messages/", app=sse.handle_post_message),
69+
],
70+
lifespan=lifespan,
71+
)

src/mcpm/router/example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async def main(host: str, port: int, allow_origins: List[str] = None):
2323
port: Port to bind the SSE server to
2424
allow_origins: List of allowed origins for CORS
2525
"""
26-
router = MCPRouter()
26+
router = MCPRouter(reload_server=True)
2727

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

0 commit comments

Comments
 (0)