Skip to content
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,15 @@ The Router also maintains persistent connections to MCP servers, enabling multip

For more technical details on the router's implementation and namespacing, see [`docs/router_tech_design.md`](docs/router_tech_design.md).

The Router can be shared in public network by `mcpm router share`. Be aware that the share link will be exposed to the public, make sure the generated secret is secure and only share to trusted users. See [MCPM Router Share](docs/router_share.md) for more details about how it works.

```bash
mcpm router status # Check if the router daemon is running
mcpm router on # Start the MCP router daemon
mcpm router off # Stop the MCP router daemon
mcpm set --host HOST --port PORT # Set the MCP router daemon's host and port
mcpm router set --host HOST --port PORT --address ADDRESS # Set the MCP router daemon's host port and the remote share address
mcpm router share # Share the router to public
mcpm router unshare # Unshare the router
```

### 🛠️ Utilities (`util`)
Expand Down
6 changes: 5 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,15 @@ MCPM 路由器作为后台守护进程运行,充当稳定端点(例如 `http

有关路由器实现和命名空间的更多技术细节,请参阅 [`docs/router_tech_design.md`](docs/router_tech_design.md)。

Router可以通过命令`mcpm router share`来将router分享到公网。注意确保生成的密钥没有暴露,并只分享给可信用户。有关分享的更多细节,请参阅[分享](docs/router_share.md)。

```bash
mcpm router status # 检查路由器守护进程是否正在运行
mcpm router on # 启动 MCP 路由器守护进程
mcpm router off # 停止 MCP 路由器守护进程
mcpm set --host HOST --port PORT # 设置 MCP 路由器守护进程的主机和端口
mcpm router set --host HOST --port PORT --address ADDRESS # 设置 MCP 路由器守护进程的主机,端口和分享的远程服务器
mcpm router share # 将router分享到公网
mcpm router unshare # 取消分享
```

### 🛠️ 实用工具 (`util`)
Expand Down
46 changes: 46 additions & 0 deletions docs/router_share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@

# MCPM Router Share

## Introduction
Your local MCPM Router can be shared in public network and others can connect to your router by the share link and use your configured MCPM Profile. In this document, we will explain how to use it and how it works.

## Create a share link

```bash
mcpm router share
mcpm router share --profile <PROFILE_NAME> --address <ADDRESS>
```
There will be a share link and a secret. The final share link will be `http://<ADDRESS>?s=<SECRET>&profile=<PROFILE_NAME>`. You can share this link with others and by adding this share link to mcpm client, they can connect to your router.

If address is not specified, the share link will be proxied by our server `share.mcpm.sh`. You can also specify a custom address to share.

If profile is not specified, the share link will use the current active profile. If no active profile found, the user need to specify the profile manually.

To be noted that if your router is not on or your system sleeps, the shared link will not be accessible.

## How it works

We use a fork version of frp from [huggingface/frp](https://github.com/huggingface/frp) to create a tunnel to your local MCPM Router. You can also check the [original frp](https://github.com/fatedier/frp) for more details about frp.

If you want to set up your own frp tunnel, you can either build our docker image from scratch or use our published docker images for frps(server) and frpc(client) by following the instructions below.

In your public server, you can create a frps config following the guide [here](https://github.com/huggingface/frp?tab=readme-ov-file#setting-up-a-share-server). Then start the frps container by:
```bash
docker run -d --name frps -p 7000:7000 -p 7001:7001 -v /path/to/frps.ini:/frp/frps.ini ghcr.io/pathintegral-institute/frps:latest
```

Then you can share the router with your own frp server by specifying the address:
```bash
mcpm router share --address <YOUR_ADDRESS>
```

## Authentication
There will be a secret token generated for authentication. The user MUST specify the secret token as a query parameter `s=<SECRET>` when connecting to your router. Make sure to keep the secret token secure and only share it with trusted users.

## Unshare

```bash
mcpm router unshare
```

This will stop the tunnel and remove the share link.
2 changes: 1 addition & 1 deletion src/mcpm/commands/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def add(profile, force=False):
profile_config_manager.new_profile(profile)

console.print(f"\n[green]Profile '{profile}' added successfully.[/]\n")
console.print(f"You can now add servers to this profile with 'mcpm add --profile {profile} <server_name>'\n")
console.print(f"You can now add servers to this profile with 'mcpm add --target #{profile} <server_name>'\n")
console.print(
f"Or apply existing config to this profile with 'mcpm profile apply {profile} --server <server_name>'\n"
)
Expand Down
132 changes: 127 additions & 5 deletions src/mcpm/commands/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@

import logging
import os
import secrets
import signal
import subprocess
import sys
import uuid

import click
import psutil
from rich.console import Console
from rich.prompt import Confirm

from mcpm.clients.client_registry import ClientRegistry
from mcpm.router.share import Tunnel
from mcpm.utils.config import ROUTER_SERVER_NAME, ConfigManager
from mcpm.utils.platform import get_log_directory, get_pid_directory

Expand All @@ -24,6 +27,7 @@
APP_SUPPORT_DIR = get_pid_directory("mcpm")
APP_SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE = APP_SUPPORT_DIR / "router.pid"
SHARE_CONFIG = APP_SUPPORT_DIR / "share.json"

LOG_DIR = get_log_directory("mcpm")
LOG_DIR.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -147,16 +151,19 @@ def start_router():
@router.command(name="set")
@click.option("-H", "--host", type=str, help="Host to bind the SSE server to")
@click.option("-p", "--port", type=int, help="Port to bind the SSE server to")
@click.option("-a", "--address", type=str, help="Remote address to share the router")
@click.help_option("-h", "--help")
def set_router_config(host, port):
def set_router_config(host, port, address):
"""Set MCPRouter global configuration.

Example:
mcpm router set -H localhost -p 8888
mcpm router set --host 127.0.0.1 --port 9000
"""
if not host and not port:
console.print("[yellow]No changes were made. Please specify at least one option (--host or --port)[/]")
if not host and not port and not address:
console.print(
"[yellow]No changes were made. Please specify at least one option (--host, --port, or --address)[/]"
)
return

# get current config, make sure all field are filled by default value if not exists
Expand All @@ -166,10 +173,13 @@ def set_router_config(host, port):
# if user does not specify a host, use current config
host = host or current_config["host"]
port = port or current_config["port"]
share_address = address or current_config["share_address"]

# save config
if config_manager.save_router_config(host, port):
console.print(f"[bold green]Router configuration updated:[/] host={host}, port={port}")
if config_manager.save_router_config(host, port, share_address):
console.print(
f"[bold green]Router configuration updated:[/] host={host}, port={port}, share_address={share_address}"
)
console.print("The new configuration will be used next time you start the router.")

# if router is running, prompt user to restart
Expand Down Expand Up @@ -223,6 +233,14 @@ def stop_router():

# send termination signal
try:
config_manager = ConfigManager()
share_config = config_manager.read_share_config()
if share_config.get("pid"):
console.print("[green]Disabling share link...[/]")
os.kill(share_config["pid"], signal.SIGTERM)
config_manager.save_share_config(share_url=None, share_pid=None, api_key=None)
console.print("[bold green]Share link disabled[/]")

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

Expand Down Expand Up @@ -256,5 +274,109 @@ def router_status():
pid = read_pid_file()
if pid:
console.print(f"[bold green]MCPRouter is running[/] at http://{host}:{port} (PID: {pid})")
share_config = ConfigManager().read_share_config()
if share_config.get("pid"):
console.print(f"[bold green]MCPRouter is sharing[/] at {share_config['url']} (PID: {share_config['pid']})")
else:
console.print("[yellow]MCPRouter is not running.[/]")


@router.command()
@click.help_option("-h", "--help")
@click.option("-a", "--address", type=str, required=False, help="Remote address to bind the tunnel to")
@click.option("-p", "--profile", type=str, required=False, help="Profile to share")
def share(address, profile):
"""Create a share link for the MCPRouter daemon process.

Example:

\b
mcpm router share --address example.com:8877
"""

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

if not profile:
active_profile = ClientRegistry.get_active_profile()
if not active_profile:
console.print("[yellow]No active profile found. You need to specify a profile to share.[/]")

console.print(f"[cyan]Sharing with active profile {active_profile}...[/]")
profile = active_profile
else:
console.print(f"[cyan]Sharing with profile {profile}...[/]")

# check if share link is already active
share_config = config_manager.read_share_config()
if share_config.get("pid"):
console.print(f"[yellow]Share link is already active at {share_config['url']}.[/]")
return

# get share address
if not address:
console.print("[cyan]Using share address from config...[/]")
config = config_manager.get_router_config()
address = config["share_address"]

# create share link
remote_host, remote_port = address.split(":")

# start tunnel
# TODO: tls certificate if necessary
tunnel = Tunnel(remote_host, remote_port, config["host"], config["port"], secrets.token_urlsafe(32), None)
share_url = tunnel.start_tunnel()
share_pid = tunnel.proc.pid if tunnel.proc else None
# generate random api key
api_key = str(uuid.uuid4())
console.print(f"[bold green]Generated secret for share link: {api_key}[/]")
# TODO: https is not supported yet
share_url = share_url.replace("https://", "http://") + "/sse"
# save share pid and link to config
config_manager.save_share_config(share_url, share_pid, api_key)
profile = profile or "<your_profile>"

# print share link
console.print(f"[bold green]Router is sharing at {share_url}[/]")
console.print(f"[green]Your profile can be accessed with the url {share_url}?s={api_key}&profile={profile}[/]\n")
console.print(
"[bold yellow]Be careful about the share link, it will be exposed to the public. Make sure to share to trusted users only.[/]"
)


@router.command("unshare")
@click.help_option("-h", "--help")
def stop_share():
"""Stop the share link for the MCPRouter daemon process."""
# check if there is a share link already running
config_manager = ConfigManager()
share_config = config_manager.read_share_config()
if not share_config["url"]:
console.print("[yellow]No share link is active.[/]")
return

pid = share_config["pid"]
if not pid:
console.print("[yellow]No share link is active.[/]")
return

# send termination signal
try:
console.print(f"[bold yellow]Stopping share link at {share_config['url']} (PID: {pid})...[/]")
os.kill(pid, signal.SIGTERM)
console.print(f"[bold green]Share process stopped (PID: {pid})[/]")

# delete share config
config_manager.save_share_config(share_url=None, share_pid=None, api_key=None)
except OSError as e:
console.print(f"[bold red]Error:[/] Failed to stop share link: {e}")

# if process does not exist, clean up share config
if e.errno == 3: # "No such process"
console.print("[yellow]Share process does not exist, cleaning up share config...[/]")
config_manager.save_share_config(share_url=None, share_pid=None, api_key=None)
console.print("[bold green]Share link disabled[/]")
Loading