diff --git a/README.md b/README.md index 32285ada..5ad1a3d9 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/README.zh-CN.md b/README.zh-CN.md index d2168a5c..e4b6ba19 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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`) diff --git a/docs/router_share.md b/docs/router_share.md new file mode 100644 index 00000000..2fbca258 --- /dev/null +++ b/docs/router_share.md @@ -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 --address
+``` +There will be a share link and a secret. The final share link will be `http://
?s=&profile=`. 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 +``` + +## Authentication +There will be a secret token generated for authentication. The user MUST specify the secret token as a query parameter `s=` 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. diff --git a/src/mcpm/commands/profile.py b/src/mcpm/commands/profile.py index 944ef6e8..9cb981f6 100644 --- a/src/mcpm/commands/profile.py +++ b/src/mcpm/commands/profile.py @@ -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} '\n") + console.print(f"You can now add servers to this profile with 'mcpm add --target #{profile} '\n") console.print( f"Or apply existing config to this profile with 'mcpm profile apply {profile} --server '\n" ) diff --git a/src/mcpm/commands/router.py b/src/mcpm/commands/router.py index fa0efba6..f0d17a95 100644 --- a/src/mcpm/commands/router.py +++ b/src/mcpm/commands/router.py @@ -4,9 +4,11 @@ import logging import os +import secrets import signal import subprocess import sys +import uuid import click import psutil @@ -14,6 +16,7 @@ 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 @@ -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) @@ -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 @@ -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 @@ -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})[/]") @@ -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 "" + + # 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[/]") diff --git a/src/mcpm/router/share.py b/src/mcpm/router/share.py new file mode 100644 index 00000000..7610fd8e --- /dev/null +++ b/src/mcpm/router/share.py @@ -0,0 +1,185 @@ +import hashlib +import os +import platform +import re +import stat +import subprocess +import sys +import time +from pathlib import Path + +import httpx + +from mcpm.utils.config import DEFAULT_CONFIG_DIR +from mcpm.utils.platform import get_frpc_directory + +VERSION = "0.3" + +machine = platform.machine() +if machine == "x86_64": + machine = "amd64" +elif machine == "aarch64": + machine = "arm64" + +BINARY_REMOTE_NAME = f"frpc_{platform.system().lower()}_{machine.lower()}" +EXTENSION = ".exe" if os.name == "nt" else "" +BINARY_URL = f"https://cdn-media.huggingface.co/frpc-gradio-{VERSION}/{BINARY_REMOTE_NAME}{EXTENSION}" + +CHECKSUMS = { + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_windows_amd64.exe": "14bc0ea470be5d67d79a07412bd21de8a0a179c6ac1116d7764f68e942dc9ceb", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_amd64": "c791d1f047b41ff5885772fc4bf20b797c6059bbd82abb9e31de15e55d6a57c4", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_arm64": "823ced25104de6dc3c9f4798dbb43f20e681207279e6ab89c40e2176ccbf70cd", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_darwin_amd64": "930f8face3365810ce16689da81b7d1941fda4466225a7bbcbced9a2916a6e15", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_darwin_arm64": "dfac50c690aca459ed5158fad8bfbe99f9282baf4166cf7c410a6673fbc1f327", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_arm": "4b563beb2e36c448cc688174e20b53af38dc1ff2b5e362d4ddd1401f2affbfb7", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_freebsd_386": "cb0a56c764ecf96dd54ed601d240c564f060ee4e58202d65ffca17c1a51ce19c", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_freebsd_amd64": "516d9e6903513869a011ddcd1ec206167ad1eb5dd6640d21057acc258edecbbb", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_386": "4c2f2a48cd71571498c0ac8a4d42a055f22cb7f14b4b5a2b0d584220fd60a283", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mips": "b309ecd594d4f0f7f33e556a80d4b67aef9319c00a8334648a618e56b23cb9e0", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mips64": "0372ef5505baa6f3b64c6295a86541b24b7b0dbe4ef28b344992e21f47624b7b", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_riscv64": "1658eed7e8c14ea76e1d95749d58441ce24147c3d559381832c725c29cfc3df3", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mipsle": "a2aaba16961d3372b79bd7a28976fcd0f0bbaebc2b50d5a7a71af2240747960f", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_windows_386.exe": "721b90550195a83e15f2176d8f85a48d5a25822757cb872e9723d4bccc4e5bb6", + "https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mips64le": "796481edd609f31962b45cc0ab4c9798d040205ae3bf354ed1b72fb432d796b8", +} + +CHUNK_SIZE = 128 + +BINARY_FILENAME = f"{BINARY_REMOTE_NAME}_v{VERSION}" +BINARY_FOLDER = get_frpc_directory() +BINARY_FOLDER.mkdir(parents=True, exist_ok=True) +BINARY_PATH = str(BINARY_FOLDER / BINARY_FILENAME) + +TUNNEL_TIMEOUT_SECONDS = 30 +TUNNEL_ERROR_MESSAGE = "Could not create share URL. Please check the appended log from frpc for more information:" + +CERTIFICATE_PATH = f"{DEFAULT_CONFIG_DIR}/certificate.pem" + + +class Tunnel: + def __init__( + self, + remote_host: str, + remote_port: int, + local_host: str, + local_port: int, + share_token: str, + share_server_tls_certificate: str | None, + ): + self.proc = None + self.url = None + self.remote_host = remote_host + self.remote_port = remote_port + self.local_host = local_host + self.local_port = local_port + self.share_token = share_token + self.share_server_tls_certificate = share_server_tls_certificate + + @staticmethod + def download_binary(): + if not Path(BINARY_PATH).exists(): + Path(BINARY_FOLDER).mkdir(parents=True, exist_ok=True) + resp = httpx.get(BINARY_URL, timeout=30) + + if resp.status_code == 403: + raise OSError( + f"Cannot set up a share link as this platform is incompatible. Please " + f"create a GitHub issue with information about your platform: {platform.uname()}" + ) + + resp.raise_for_status() + + # Save file data to local copy + with open(BINARY_PATH, "wb") as file: + file.write(resp.content) + st = os.stat(BINARY_PATH) + os.chmod(BINARY_PATH, st.st_mode | stat.S_IEXEC) + + if BINARY_URL in CHECKSUMS: + sha = hashlib.sha256() + with open(BINARY_PATH, "rb") as f: + for chunk in iter(lambda: f.read(CHUNK_SIZE * sha.block_size), b""): + sha.update(chunk) + calculated_hash = sha.hexdigest() + + if calculated_hash != CHECKSUMS[BINARY_URL]: + raise ValueError("Checksum mismatch for frpc binary") + + def start_tunnel(self) -> str: + self.download_binary() + self.url = self._start_tunnel(BINARY_PATH) + return self.url + + def kill(self): + if self.proc is not None: + print(f"Killing tunnel {self.local_host}:{self.local_port} <> {self.url}") + self.proc.terminate() + self.proc = None + + def _start_tunnel(self, binary: str) -> str: + command = [ + binary, + "http", + "-n", + self.share_token, + "-l", + str(self.local_port), + "-i", + self.local_host, + "--uc", + "--sd", + "random", + "--ue", + "--server_addr", + f"{self.remote_host}:{self.remote_port}", + "--disable_log_color", + ] + if self.share_server_tls_certificate is not None: + command.extend( + [ + "--tls_enable", + "--tls_trusted_ca_file", + self.share_server_tls_certificate, + ] + ) + self.proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + return self._read_url_from_tunnel_stream() + + def _read_url_from_tunnel_stream(self) -> str: + start_timestamp = time.time() + + log = [] + url = "" + + def _raise_tunnel_error(): + log_text = "\n".join(log) + print(log_text, file=sys.stderr) + raise ValueError(f"{TUNNEL_ERROR_MESSAGE}\n{log_text}") + + while url == "": + # check for timeout and log + if time.time() - start_timestamp >= TUNNEL_TIMEOUT_SECONDS: + _raise_tunnel_error() + + assert self.proc is not None # noqa: S101 + if self.proc.stdout is None: + continue + + line = self.proc.stdout.readline() + line = line.decode("utf-8") + + if line == "": + continue + + log.append(line.strip()) + + if "start proxy success" in line: + result = re.search("start proxy success: (.+)\n", line) + if result is None: + _raise_tunnel_error() + else: + url = result.group(1) + elif "login to server failed" in line: + _raise_tunnel_error() + + return url diff --git a/src/mcpm/router/transport.py b/src/mcpm/router/transport.py index eb2d2e4a..7b151c82 100644 --- a/src/mcpm/router/transport.py +++ b/src/mcpm/router/transport.py @@ -3,7 +3,7 @@ import logging from contextlib import asynccontextmanager from typing import Any, TypedDict -from urllib.parse import quote +from urllib.parse import quote, urlsplit from uuid import UUID, uuid4 import anyio @@ -17,21 +17,19 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send +from mcpm.utils.config import ConfigManager + logger = logging.getLogger(__name__) class ClientIdentifier(TypedDict): client_id: str profile: str + api_key: str | None def patch_meta_data(body: bytes, **kwargs) -> bytes: data = json.loads(body.decode("utf-8")) - if "params" not in data: - data["params"] = {} - - if "_meta" not in data["params"]: - data["params"]["_meta"] = {} for key, value in kwargs.items(): data["params"]["_meta"][key] = value @@ -77,6 +75,13 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send): logger.error("connect_sse received non-HTTP request") raise ValueError("connect_sse can only handle HTTP requests") + # check api key + api_key = get_key_from_scope(scope, key_name="s") + if not self._validate_api_key(scope, api_key): + response = Response("Unauthorized API key", status_code=401) + await response(scope, receive, send) + return + logger.debug("Setting up SSE connection") read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] @@ -96,7 +101,7 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send): client_id = get_key_from_scope(scope, key_name="client") if profile is not None: self._session_id_to_identifier[session_id] = ClientIdentifier( - client_id=client_id or "anonymous", profile=profile + client_id=client_id or "anonymous", profile=profile, api_key=api_key ) logger.debug(f"Session {session_id} mapped to identifier {self._session_id_to_identifier[session_id]}") @@ -189,8 +194,15 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send): response = Response("Could not find identifier", status_code=404) return await response(scope, receive, send) + # check api key + api_key = identifier["api_key"] + if not self._validate_api_key(scope, api_key): + response = Response("Unauthorized API key", status_code=401) + await response(scope, receive, send) + return + # append profile to params metadata so that the downstream mcp server could attach - body = patch_meta_data(body, profile=identifier["profile"], client_id=identifier["client_id"]) + body = patch_meta_data(body, profile=identifier["profile"], client_id=identifier["client_id"], api_key=api_key) try: message = types.JSONRPCMessage.model_validate_json(body) @@ -220,3 +232,23 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send): logger.warning(f"Connection error when sending message to session {session_id}: {e}") self._read_stream_writers.pop(session_id, None) self._session_id_to_identifier.pop(session_id, None) + + def _validate_api_key(self, scope: Scope, api_key: str | None) -> bool: + try: + config_manager = ConfigManager() + host = get_key_from_scope(scope, key_name="host") or "" + if not host.startswith("http"): + host = f"http://{host}" + share_config = config_manager.read_share_config() + router_config = config_manager.get_router_config() + host_name = urlsplit(host).hostname + share_host_name = urlsplit(share_config["url"]).hostname + if share_config["url"] and (host_name == share_host_name or host_name != router_config["host"]): + share_api_key = share_config["api_key"] + if api_key != share_api_key: + logger.warning("Unauthorized API key") + return False + except Exception as e: + logger.error(f"Failed to validate API key: {e}") + return False + return True diff --git a/src/mcpm/utils/config.py b/src/mcpm/utils/config.py index 94a34e27..77a7c466 100644 --- a/src/mcpm/utils/config.py +++ b/src/mcpm/utils/config.py @@ -21,6 +21,7 @@ RESOURCE_SPLITOR = ":" RESOURCE_TEMPLATE_SPLITOR = ":" PROMPT_SPLITOR = "_p_" +DEFAULT_SHARE_ADDRESS = f"share.mcpm.sh:{DEFAULT_PORT}" class ConfigManager: @@ -100,7 +101,7 @@ def get_router_config(self): # check if router config exists if "router" not in config: # create default config and save - router_config = {"host": DEFAULT_HOST, "port": DEFAULT_PORT} + router_config = {"host": DEFAULT_HOST, "port": DEFAULT_PORT, "share_address": DEFAULT_SHARE_ADDRESS} self.set_config("router", router_config) return router_config @@ -116,6 +117,9 @@ def get_router_config(self): if "port" not in router_config: router_config["port"] = DEFAULT_PORT updated = True + if "share_address" not in router_config: + router_config["share_address"] = DEFAULT_SHARE_ADDRESS + updated = True # save config if updated if updated: @@ -123,13 +127,20 @@ def get_router_config(self): return router_config - def save_router_config(self, host, port): + def save_router_config(self, host, port, share_address): """save router configuration to config file""" router_config = self.get_config().get("router", {}) # update config router_config["host"] = host router_config["port"] = port + router_config["share_address"] = share_address # save config return self.set_config("router", router_config) + + def save_share_config(self, share_url: str | None = None, share_pid: int | None = None, api_key: str | None = None): + return self.set_config("share", {"url": share_url, "pid": share_pid, "api_key": api_key}) + + def read_share_config(self) -> Dict[str, Any]: + return self.get_config().get("share", {}) diff --git a/src/mcpm/utils/platform.py b/src/mcpm/utils/platform.py index 1ab73466..921056ca 100644 --- a/src/mcpm/utils/platform.py +++ b/src/mcpm/utils/platform.py @@ -71,3 +71,35 @@ def get_pid_directory(app_name: str = "mcpm") -> Path: # Default to ~/.local/share if XDG_DATA_HOME is not defined return Path.home() / ".local" / "share" / app_name + + +def get_frpc_directory(app_name: str = "mcpm") -> Path: + """ + Return the appropriate FRPC directory path based on the current operating system. + + Args: + app_name: The name of the application, used in the path + + Returns: + Path object representing the FRPC directory + """ + # macOS + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / app_name / "frpc" + + # Windows + elif sys.platform == "win32": + localappdata = os.environ.get("LOCALAPPDATA") + if localappdata: + return Path(localappdata) / app_name / "frpc" + return Path.home() / "AppData" / "Local" / app_name / "frpc" + + # Linux and other Unix-like systems + else: + # Check if XDG_DATA_HOME is defined + xdg_data_home = os.environ.get("XDG_DATA_HOME") + if xdg_data_home: + return Path(xdg_data_home) / app_name / "frpc" + + # Default to ~/.local/share if XDG_DATA_HOME is not defined + return Path.home() / ".local" / "share" / app_name / "frpc"