Skip to content

Commit d07397a

Browse files
feat: add share for router
1 parent 88fca23 commit d07397a

File tree

8 files changed

+462
-18
lines changed

8 files changed

+462
-18
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,15 @@ The Router also maintains persistent connections to MCP servers, enabling multip
152152

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

155+
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.
156+
155157
```bash
156158
mcpm router status # Check if the router daemon is running
157159
mcpm router on # Start the MCP router daemon
158160
mcpm router off # Stop the MCP router daemon
159-
mcpm set --host HOST --port PORT # Set the MCP router daemon's host and port
161+
mcpm router set --host HOST --port PORT --address ADDRESS # Set the MCP router daemon's host port and the remote share address
162+
mcpm router share # Share the router to public
163+
mcpm router unshare # Unshare the router
160164
```
161165

162166
### 🛠️ Utilities (`util`)

README.zh-CN.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,15 @@ MCPM 路由器作为后台守护进程运行,充当稳定端点(例如 `http
152152

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

155+
Router可以通过命令`mcpm router share`来将router分享到公网。注意确保生成的密钥没有暴露,并只分享给可信用户。有关分享的更多细节,请参阅[分享](docs/router_share.md)
156+
155157
```bash
156158
mcpm router status # 检查路由器守护进程是否正在运行
157159
mcpm router on # 启动 MCP 路由器守护进程
158160
mcpm router off # 停止 MCP 路由器守护进程
159-
mcpm set --host HOST --port PORT # 设置 MCP 路由器守护进程的主机和端口
161+
mcpm router set --host HOST --port PORT --address ADDRESS # 设置 MCP 路由器守护进程的主机,端口和分享的远程服务器
162+
mcpm router share # 将router分享到公网
163+
mcpm router unshare # 取消分享
160164
```
161165

162166
### 🛠️ 实用工具 (`util`)

docs/router_share.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
# MCPM Router Share
3+
4+
## Introduction
5+
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.
6+
7+
## Create a share link
8+
9+
```bash
10+
mcpm router share
11+
mcpm router share --profile <PROFILE_NAME> --address <ADDRESS>
12+
```
13+
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.
14+
15+
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.
16+
17+
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.
18+
19+
To be noted that if your router is not running or your system sleeps, the share link will not be available.
20+
21+
## How it works
22+
23+
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.
24+
25+
If you want to set up your own frp tunnel, we have build a docker image for frps(server) and frpc(client).
26+
27+
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:
28+
```bash
29+
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
30+
```
31+
32+
Then you can share the router with your own frp server by specifying the address:
33+
```bash
34+
mcpm router share --address <YOUR_ADDRESS>
35+
```
36+
37+
## Authentication
38+
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.
39+
40+
## Unshare
41+
42+
```bash
43+
mcpm router unshare
44+
```
45+
46+
This will stop the tunnel and remove the share link.

src/mcpm/commands/router.py

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
44

55
import logging
66
import os
7+
import secrets
78
import signal
89
import subprocess
910
import sys
11+
import uuid
1012

1113
import click
1214
import psutil
1315
from rich.console import Console
1416
from rich.prompt import Confirm
1517

1618
from mcpm.clients.client_registry import ClientRegistry
19+
from mcpm.router.share import Tunnel
1720
from mcpm.utils.config import ROUTER_SERVER_NAME, ConfigManager
1821
from mcpm.utils.platform import get_log_directory, get_pid_directory
1922

@@ -24,6 +27,7 @@
2427
APP_SUPPORT_DIR = get_pid_directory("mcpm")
2528
APP_SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
2629
PID_FILE = APP_SUPPORT_DIR / "router.pid"
30+
SHARE_CONFIG = APP_SUPPORT_DIR / "share.json"
2731

2832
LOG_DIR = get_log_directory("mcpm")
2933
LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -147,16 +151,19 @@ def start_router():
147151
@router.command(name="set")
148152
@click.option("-H", "--host", type=str, help="Host to bind the SSE server to")
149153
@click.option("-p", "--port", type=int, help="Port to bind the SSE server to")
154+
@click.option("-a", "--address", type=str, help="Remote address to share the router")
150155
@click.help_option("-h", "--help")
151-
def set_router_config(host, port):
156+
def set_router_config(host, port, address):
152157
"""Set MCPRouter global configuration.
153158
154159
Example:
155160
mcpm router set -H localhost -p 8888
156161
mcpm router set --host 127.0.0.1 --port 9000
157162
"""
158-
if not host and not port:
159-
console.print("[yellow]No changes were made. Please specify at least one option (--host or --port)[/]")
163+
if not host and not port and not address:
164+
console.print(
165+
"[yellow]No changes were made. Please specify at least one option (--host, --port, or --address)[/]"
166+
)
160167
return
161168

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

170178
# save config
171-
if config_manager.save_router_config(host, port):
172-
console.print(f"[bold green]Router configuration updated:[/] host={host}, port={port}")
179+
if config_manager.save_router_config(host, port, share_address):
180+
console.print(
181+
f"[bold green]Router configuration updated:[/] host={host}, port={port}, share_address={share_address}"
182+
)
173183
console.print("The new configuration will be used next time you start the router.")
174184

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

224234
# send termination signal
225235
try:
236+
config_manager = ConfigManager()
237+
share_config = config_manager.read_share_config()
238+
if share_config.get("pid"):
239+
console.print("[green]Disabling share link...[/]")
240+
os.kill(share_config["pid"], signal.SIGTERM)
241+
config_manager.save_share_config(share_url=None, share_pid=None, api_key=None)
242+
console.print("[bold green]Share link disabled[/]")
243+
226244
os.kill(pid, signal.SIGTERM)
227245
console.print(f"[bold green]MCPRouter stopped (PID: {pid})[/]")
228246

@@ -256,5 +274,109 @@ def router_status():
256274
pid = read_pid_file()
257275
if pid:
258276
console.print(f"[bold green]MCPRouter is running[/] at http://{host}:{port} (PID: {pid})")
277+
share_config = ConfigManager().read_share_config()
278+
if share_config.get("pid"):
279+
console.print(f"[bold green]MCPRouter is sharing[/] at {share_config['url']} (PID: {share_config['pid']})")
259280
else:
260281
console.print("[yellow]MCPRouter is not running.[/]")
282+
283+
284+
@router.command()
285+
@click.help_option("-h", "--help")
286+
@click.option("-a", "--address", type=str, required=False, help="Remote address to bind the tunnel to")
287+
@click.option("-p", "--profile", type=str, required=False, help="Profile to share")
288+
def share(address, profile):
289+
"""Create a share link for the MCPRouter daemon process.
290+
291+
Example:
292+
293+
\b
294+
mcpm router share --address example.com:8877
295+
"""
296+
297+
# check if there is a router already running
298+
pid = read_pid_file()
299+
config_manager = ConfigManager()
300+
if not pid:
301+
console.print("[yellow]MCPRouter is not running.[/]")
302+
return
303+
304+
if not profile:
305+
active_profile = ClientRegistry.get_active_profile()
306+
if not active_profile:
307+
console.print("[yellow]No active profile found. You need to specify a profile to share.[/]")
308+
309+
console.print(f"[cyan]Sharing with active profile {active_profile}...[/]")
310+
profile = active_profile
311+
else:
312+
console.print(f"[cyan]Sharing with profile {profile}...[/]")
313+
314+
# check if share link is already active
315+
share_config = config_manager.read_share_config()
316+
if share_config.get("pid"):
317+
console.print(f"[yellow]Share link is already active at {share_config['url']}.[/]")
318+
return
319+
320+
# get share address
321+
if not address:
322+
console.print("[cyan]Using share address from config...[/]")
323+
config = config_manager.get_router_config()
324+
address = config["share_address"]
325+
326+
# create share link
327+
remote_host, remote_port = address.split(":")
328+
329+
# start tunnel
330+
# TODO: tls certificate if necessary
331+
tunnel = Tunnel(remote_host, remote_port, config["host"], config["port"], secrets.token_urlsafe(32), None)
332+
share_url = tunnel.start_tunnel()
333+
share_pid = tunnel.proc.pid if tunnel.proc else None
334+
# generate random api key
335+
api_key = str(uuid.uuid4())
336+
console.print(f"[bold green]Generated secret for share link: {api_key}[/]")
337+
# TODO: https is not supported yet
338+
share_url = share_url.replace("https://", "http://") + "/sse"
339+
# save share pid and link to config
340+
config_manager.save_share_config(share_url, share_pid, api_key)
341+
profile = profile or "<your_profile>"
342+
343+
# print share link
344+
console.print(f"[bold green]Router is sharing at {share_url}[/]")
345+
console.print(f"[green]Your profile can be accessed with the url {share_url}?s={api_key}&profile={profile}[/]\n")
346+
console.print(
347+
"[bold yellow]Be careful about the share link, it will be exposed to the public. Make sure to share to trusted users only.[/]"
348+
)
349+
350+
351+
@router.command("unshare")
352+
@click.help_option("-h", "--help")
353+
def stop_share():
354+
"""Stop the share link for the MCPRouter daemon process."""
355+
# check if there is a share link already running
356+
config_manager = ConfigManager()
357+
share_config = config_manager.read_share_config()
358+
if not share_config["url"]:
359+
console.print("[yellow]No share link is active.[/]")
360+
return
361+
362+
pid = share_config["pid"]
363+
if not pid:
364+
console.print("[yellow]No share link is active.[/]")
365+
return
366+
367+
# send termination signal
368+
try:
369+
console.print(f"[bold yellow]Stopping share link at {share_config['url']} (PID: {pid})...[/]")
370+
os.kill(pid, signal.SIGTERM)
371+
console.print(f"[bold green]Share process stopped (PID: {pid})[/]")
372+
373+
# delete share config
374+
config_manager.save_share_config(share_url=None, share_pid=None, api_key=None)
375+
except OSError as e:
376+
console.print(f"[bold red]Error:[/] Failed to stop share link: {e}")
377+
378+
# if process does not exist, clean up share config
379+
if e.errno == 3: # "No such process"
380+
console.print("[yellow]Share process does not exist, cleaning up share config...[/]")
381+
config_manager.save_share_config(share_url=None, share_pid=None, api_key=None)
382+
console.print("[bold green]Share link disabled[/]")

0 commit comments

Comments
 (0)