-
Notifications
You must be signed in to change notification settings - Fork 68
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
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d45800b
Add hot reload support for profile config and improve logging
calmini 8b2493b
Merge branch 'caozhen/dynamic-reload-servers' into caozhen/mcpm-route…
calmini 72e996f
feat: add router command for managing MCP server aggregation daemon
calmini bc90140
Merge branch 'main' into caozhen/mcpm-router-command
calmini bd1d383
Merge branch 'main' into caozhen/mcpm-router-command
calmini dcdcab8
Add cross-platform support for log and PID file directories
calmini File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.[/]") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.