#!/usr/bin/env python3 """Manage the user-level read-only HTTP NocoBase CRM MCP systemd service.""" from __future__ import annotations import argparse import os import secrets import shlex import shutil import socket import subprocess import urllib.error import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Sequence SERVICE_NAME = "nocobase-crm-mcp.service" ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env" DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 10087 DEFAULT_PATH = "/mcp" DEFAULT_NB_ENV = "nb-mcp" DEFAULT_CONFIG_PATH = "config.json" DEFAULT_TOKEN_BYTES = 32 DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" @dataclass(frozen=True) class ServicePaths: project_root: Path unit_path: Path env_path: Path def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str: return secrets.token_urlsafe(num_bytes) def systemd_quote(value: str | Path) -> str: return shlex.quote(str(value)) def find_uv_path() -> Path: configured = os.environ.get("UV_PATH", "").strip() discovered = configured or shutil.which("uv") if not discovered: raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute path.") return Path(discovered).expanduser().resolve() def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths: root = project_root.expanduser().resolve() base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH) def render_service_unit( *, project_root: Path, env_file: Path, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH, uv_cache_dir: str = DEFAULT_UV_CACHE_DIR, uv_path: Path | None = None, ) -> str: resolved_uv_path = uv_path or find_uv_path() command = " ".join( [ systemd_quote(resolved_uv_path), "run", "--no-sync", "nocobase-crm-mcp", "--transport", "streamable-http", "--host", host, "--port", str(port), "--path", path, ] ) return ( "[Unit]\n" "Description=NocoBase CRM readonly HTTP MCP server\n" "After=network-online.target\n" "Wants=network-online.target\n" "\n" "[Service]\n" "Type=simple\n" f"WorkingDirectory={systemd_quote(project_root)}\n" f"EnvironmentFile={systemd_quote(env_file)}\n" f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n" f"ExecStart={command}\n" "Restart=on-failure\n" "RestartSec=5\n" "\n" "[Install]\n" "WantedBy=default.target\n" ) def write_token_env_file( env_path: Path, token: str, *, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH, nb_env: str = DEFAULT_NB_ENV, config_path: str = DEFAULT_CONFIG_PATH, ) -> None: env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.chmod(0o700) fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as env_file: env_file.write(f"NOCOBASE_MCP_TOKEN={token}\n") env_file.write(f"NOCOBASE_MCP_HOST={host}\n") env_file.write(f"NOCOBASE_MCP_PORT={port}\n") env_file.write(f"NOCOBASE_MCP_PATH={path}\n") env_file.write(f"NOCOBASE_MCP_ENV={nb_env}\n") env_file.write(f"NOCOBASE_MCP_CONFIG={config_path}\n") env_path.chmod(0o600) def read_service_env_file(env_path: Path) -> dict[str, str]: if not env_path.exists(): raise RuntimeError( f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it, " "or scripts/mcp_service.sh new-token to rotate it." ) values: dict[str, str] = {} for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue key, separator, value = line.partition("=") if separator: values[key.strip()] = value.strip() return values def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, str]: paths = resolve_service_paths(project_root) values = read_service_env_file(paths.env_path) token = values.get("NOCOBASE_MCP_TOKEN", "").strip() if not token: raise RuntimeError(f"Missing NOCOBASE_MCP_TOKEN in {paths.env_path}") host = values.get("NOCOBASE_MCP_HOST") or DEFAULT_HOST port = int(values.get("NOCOBASE_MCP_PORT") or DEFAULT_PORT) path = values.get("NOCOBASE_MCP_PATH") or DEFAULT_PATH nb_env = values.get("NOCOBASE_MCP_ENV") or DEFAULT_NB_ENV config_path = values.get("NOCOBASE_MCP_CONFIG") or DEFAULT_CONFIG_PATH return token, host, port, path, nb_env, config_path def write_service_unit(paths: ServicePaths, *, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH) -> None: paths.unit_path.parent.mkdir(parents=True, exist_ok=True) unit = render_service_unit(project_root=paths.project_root, env_file=paths.env_path, host=host, port=port, path=path) paths.unit_path.write_text(unit, encoding="utf-8") def detect_remote_hosts() -> list[str]: hosts: list[str] = [] try: result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False) except OSError: result = None if result and result.returncode == 0: hosts.extend(result.stdout.split()) try: for info in socket.getaddrinfo(socket.gethostname(), None): hosts.append(info[4][0]) except OSError: pass cleaned: list[str] = [] for host in hosts: normalized = host.strip() if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"): continue if normalized not in cleaned: cleaned.append(normalized) return cleaned def render_remote_config( *, hosts: Sequence[str], token: str, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH, service_status: str | None = None, ) -> str: host = hosts[0] if hosts else "" url = f"http://{host}:{port}{path}" alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:]) alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else "" status_section = f"Service status: {service_status}\n\n" if service_status else "" return ( "Saved MCP client config.\n" "\n" f"{status_section}" "Use this URL from another machine:\n" f"{url}\n" f"{alternatives_section}" "\n" "Use this header:\n" f"Authorization: Bearer {token}\n" "\n" "Example MCP client config:\n" "{\n" ' "mcpServers": {\n' ' "nocobase-crm": {\n' ' "type": "streamable-http",\n' f' "url": "{url}",\n' ' "headers": {\n' f' "Authorization": "Bearer {token}"\n' " }\n" " }\n" " }\n" "}\n" ) def show_config(project_root: Path) -> str: token, _host, port, path, _nb_env, _config_path = saved_service_config(project_root) return render_remote_config( hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status=service_state(), ) def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run(["systemctl", "--user", *args], text=True, check=check) def service_state() -> str: result = subprocess.run( ["systemctl", "--user", "is-active", SERVICE_NAME], capture_output=True, text=True, check=False, ) state = result.stdout.strip() if state: return state if result.returncode == 0: return "active" return "unknown" def probe_http_service(host: str, port: int, path: str, *, token: str, timeout: float = 2.0) -> dict[str, object]: url = f"http://{host}:{port}{path}" request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET") try: with urllib.request.urlopen(request, timeout=timeout) as response: return {"reachable": True, "status": response.status, "url": url} except urllib.error.HTTPError as error: return {"reachable": True, "status": error.code, "url": url} except OSError as error: return {"reachable": False, "error": str(error), "url": url} def restart_service_if_installed(paths: ServicePaths) -> bool: if not paths.unit_path.exists(): return False run_systemctl(["restart", SERVICE_NAME]) return True def new_token( *, project_root: Path, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH, nb_env: str = DEFAULT_NB_ENV, config_path: str = DEFAULT_CONFIG_PATH, token_bytes: int = DEFAULT_TOKEN_BYTES, ) -> str: paths = resolve_service_paths(project_root) token = generate_token(token_bytes) write_token_env_file(paths.env_path, token, host=host, port=port, path=path, nb_env=nb_env, config_path=config_path) restart_service_if_installed(paths) return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status=service_state()) def start_service(*, project_root: Path) -> str: paths = resolve_service_paths(project_root) if not paths.env_path.exists(): write_token_env_file( paths.env_path, generate_token(), host=DEFAULT_HOST, port=DEFAULT_PORT, path=DEFAULT_PATH, nb_env=DEFAULT_NB_ENV, config_path=DEFAULT_CONFIG_PATH, ) token, host, port, path, _nb_env, _config_path = saved_service_config(project_root) write_service_unit(paths, host=host, port=port, path=path) run_systemctl(["daemon-reload"]) run_systemctl(["enable", SERVICE_NAME]) run_systemctl(["restart", SERVICE_NAME]) try: run_systemctl(["is-active", "--quiet", SERVICE_NAME]) except subprocess.CalledProcessError as error: raise RuntimeError(f"{SERVICE_NAME} did not become active after restart") from error probe = probe_http_service("127.0.0.1", port, path, token=token) if not probe.get("reachable"): raise RuntimeError(f"{SERVICE_NAME} is active but HTTP probe failed: {probe.get('error')}") return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status="active") def stop_service() -> None: run_systemctl(["stop", SERVICE_NAME]) def uninstall_service(project_root: Path) -> None: paths = resolve_service_paths(project_root) run_systemctl(["disable", "--now", SERVICE_NAME], check=False) if paths.unit_path.exists(): paths.unit_path.unlink() if paths.env_path.exists(): paths.env_path.unlink() try: paths.env_path.parent.rmdir() except OSError: pass run_systemctl(["daemon-reload"], check=False) run_systemctl(["reset-failed", SERVICE_NAME], check=False) def service_status(*, project_root: Path) -> str: paths = resolve_service_paths(project_root) state = service_state() lines = [ f"Service status: {state}", f"Unit file: {paths.unit_path}", f"Env file: {paths.env_path}", ] if paths.env_path.exists(): token, _host, port, path, _nb_env, _config_path = saved_service_config(project_root) hosts = detect_remote_hosts() host = hosts[0] if hosts else "" lines.append(f"MCP URL: http://{host}:{port}{path}") probe = probe_http_service("127.0.0.1", port, path, token=token) if probe.get("reachable"): lines.append(f"HTTP probe: reachable (status {probe.get('status')})") else: lines.append(f"HTTP probe: not reachable ({probe.get('error')})") else: lines.append("MCP URL: unavailable until first start creates the env file") lines.append("HTTP probe: skipped") return "\n".join(lines) + "\n" def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Manage the readonly HTTP NocoBase CRM MCP user service.") subparsers = parser.add_subparsers(dest="command", required=True) new_token_parser = subparsers.add_parser("new-token", help="Generate a new token and print remote MCP client configuration.") new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) new_token_parser.add_argument("--host", default=DEFAULT_HOST) new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int) new_token_parser.add_argument("--path", default=DEFAULT_PATH) new_token_parser.add_argument("--nb-env", default=DEFAULT_NB_ENV) new_token_parser.add_argument("--config-path", default=DEFAULT_CONFIG_PATH) new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int) start_parser = subparsers.add_parser("start", help="Install or update the user service using the saved token, then start it.") start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) subparsers.add_parser("stop", help="Stop the user service without deleting service files.") uninstall_parser = subparsers.add_parser("uninstall", help="Stop and remove the managed user service and token file.") uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) show_config_parser = subparsers.add_parser("show-config", help="Print the saved token and remote MCP client configuration.") show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) status_parser = subparsers.add_parser("status", help="Show systemd status for the managed service.") status_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) args = parser.parse_args(argv) if args.command == "new-token": print( new_token( project_root=args.project_root, host=args.host, port=args.port, path=args.path, nb_env=args.nb_env, config_path=args.config_path, token_bytes=args.token_bytes, ), end="", ) return 0 if args.command == "start": print(start_service(project_root=args.project_root), end="") return 0 if args.command == "stop": stop_service() print(f"Stopped {SERVICE_NAME}.") return 0 if args.command == "uninstall": uninstall_service(args.project_root) print(f"Uninstalled {SERVICE_NAME}.") return 0 if args.command == "show-config": print(show_config(args.project_root), end="") return 0 if args.command == "status": print(service_status(project_root=args.project_root), end="") return 0 raise AssertionError(f"unhandled command: {args.command}") if __name__ == "__main__": raise SystemExit(main())