from __future__ import annotations import os import secrets import shutil import socket import subprocess import time import urllib.error import urllib.request from collections.abc import Sequence from pathlib import Path from mcp_service_config import ( API_BASE_URL_ENV, API_TOKEN_ENV, DEFAULT_CONFIG_PATH, DEFAULT_HOST, DEFAULT_NB_ENV, DEFAULT_PATH, DEFAULT_PORT, DEFAULT_TOKEN_BYTES, ENABLE_UPLOADS_ENV, LEGACY_API_BASE_URL_ENV, LEGACY_API_TOKEN_ENV, SERVICE_NAME, ServicePaths, find_uv_path, read_service_env_file, render_service_unit, resolve_service_paths, saved_service_config, write_token_env_file, ) def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str: return secrets.token_urlsafe(num_bytes) def find_nb_path() -> Path: configured = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip() candidate = configured or "nb" candidate_path = Path(candidate).expanduser() if candidate_path.is_absolute(): if candidate_path.is_file() and os.access(candidate_path, os.X_OK): return Path(os.path.abspath(candidate_path)) raise RuntimeError(f"NOCOBASE_MCP_NB_BIN points to {candidate_path}, but it is not an executable file.") discovered = shutil.which(candidate) if not discovered: raise RuntimeError( "Could not find nb on PATH. Install the NocoBase CLI or set NOCOBASE_MCP_NB_BIN to its absolute path." ) return Path(os.path.abspath(str(Path(discovered).expanduser()))) 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, uv_path=find_uv_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 "127.0.0.1" 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 loopback URL on this 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: config = saved_service_config(project_root) display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host return render_remote_config( hosts=[display_host], token=config.token, port=config.port, path=config.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 wait_for_http_service( host: str, port: int, path: str, *, token: str, attempts: int = 20, interval_seconds: float = 0.25, ) -> dict[str, object]: last_probe: dict[str, object] = {"reachable": False, "error": "not probed"} for attempt in range(max(1, attempts)): last_probe = probe_http_service(host, port, path, token=token) if last_probe.get("reachable"): return last_probe if attempt < attempts - 1: time.sleep(interval_seconds) return last_probe 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, nb_bin=find_nb_path(), ) restart_service_if_installed(paths) display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host return render_remote_config(hosts=[display_host], token=token, port=port, path=path, service_status=service_state()) def start_service(*, project_root: Path) -> str: paths = resolve_service_paths(project_root) nb_bin = find_nb_path() 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, nb_bin=nb_bin, ) config = saved_service_config(project_root) write_token_env_file( paths.env_path, config.token, host=config.host, port=config.port, path=config.path, nb_env=config.nb_env, config_path=config.config_path, nb_bin=nb_bin, ) write_service_unit(paths, host=config.host, port=config.port, path=config.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 = wait_for_http_service("127.0.0.1", config.port, config.path, token=config.token) if not probe.get("reachable"): raise RuntimeError(f"{SERVICE_NAME} is active but HTTP probe failed: {probe.get('error')}") display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host return render_remote_config( hosts=[display_host], token=config.token, port=config.port, path=config.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(): values = read_service_env_file(paths.env_path) config = saved_service_config(project_root) display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host lines.append(f"MCP URL: http://{display_host}:{config.port}{config.path}") lines.append(f"nb binary: {config.nb_bin}") lines.append(f"NocoBase CLI env: {config.nb_env} (personal API Key managed by the Agent installer)") uploads_enabled = values.get(ENABLE_UPLOADS_ENV) == "1" lines.append(f"Attachment uploads: {'enabled' if uploads_enabled else 'disabled (safe default)'}") canonical_configured = bool(values.get(API_BASE_URL_ENV) and values.get(API_TOKEN_ENV)) legacy_configured = bool(values.get(LEGACY_API_BASE_URL_ENV) and values.get(LEGACY_API_TOKEN_ENV)) if canonical_configured: credential_state = "configured (canonical)" elif legacy_configured: credential_state = "configured (legacy)" elif any( values.get(name) for name in (API_BASE_URL_ENV, API_TOKEN_ENV, LEGACY_API_BASE_URL_ENV, LEGACY_API_TOKEN_ENV) ): credential_state = "incomplete" else: credential_state = "not configured; using the local nb CLI login" lines.append(f"Legacy NocoBase credential override: {credential_state}") probe = probe_http_service("127.0.0.1", config.port, config.path, token=config.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" __all__ = [ "generate_token", "find_nb_path", "write_service_unit", "detect_remote_hosts", "render_remote_config", "show_config", "run_systemctl", "service_state", "probe_http_service", "wait_for_http_service", "restart_service_if_installed", "new_token", "start_service", "stop_service", "uninstall_service", "service_status", ]