from __future__ import annotations import os import shlex import shutil from dataclasses import dataclass from pathlib import Path SERVICE_NAME = "nocobase-crm-mcp.service" ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env" DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 10087 DEFAULT_PATH = "/mcp" DEFAULT_NB_ENV = "nb-mcp" DEFAULT_CONFIG_PATH = "config.json" DEFAULT_NB_BIN = "nb" DEFAULT_TOKEN_BYTES = 32 DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS" API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL" API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN" LEGACY_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL" LEGACY_API_TOKEN_ENV = "NOCOBASE_MCP_UPLOAD_JWT" MANAGED_ENV_NAMES = frozenset( { "NOCOBASE_MCP_TOKEN", "NOCOBASE_MCP_HOST", "NOCOBASE_MCP_PORT", "NOCOBASE_MCP_PATH", "NOCOBASE_MCP_ENV", "NOCOBASE_MCP_CONFIG", "NOCOBASE_MCP_NB_BIN", ENABLE_UPLOADS_ENV, "NOCOBASE_MCP_UPLOAD_MAX_BYTES", } ) @dataclass(frozen=True) class ServicePaths: project_root: Path unit_path: Path env_path: Path @dataclass(frozen=True) class ServiceConfig: 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 nb_bin: str = DEFAULT_NB_BIN 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 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, nb_bin: str | Path = DEFAULT_NB_BIN, ) -> None: uploads_enabled = _saved_uploads_setting(env_path) preserved_lines = _operator_env_lines(env_path) 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_file.write(f"NOCOBASE_MCP_NB_BIN={nb_bin}\n") env_file.write(f"{ENABLE_UPLOADS_ENV}={uploads_enabled}\n") env_file.write(f"NOCOBASE_MCP_UPLOAD_MAX_BYTES={DEFAULT_UPLOAD_MAX_BYTES}\n") if preserved_lines: env_file.write("\n") env_file.write("\n".join(preserved_lines)) env_file.write("\n") env_path.chmod(0o600) def _saved_uploads_setting(env_path: Path) -> str: if not env_path.exists(): return "0" for raw_line in env_path.read_text(encoding="utf-8").splitlines(): key, separator, value = raw_line.strip().partition("=") if separator and key.strip() == ENABLE_UPLOADS_ENV: return "1" if value.strip() == "1" else "0" return "0" def _operator_env_lines(env_path: Path) -> list[str]: if not env_path.exists(): return [] preserved_lines: list[str] = [] for raw_line in env_path.read_text(encoding="utf-8").splitlines(): key, separator, _value = raw_line.strip().partition("=") if separator and key.strip() in MANAGED_ENV_NAMES: continue preserved_lines.append(raw_line) return preserved_lines 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) -> ServiceConfig: 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 nb_bin = values.get("NOCOBASE_MCP_NB_BIN") or DEFAULT_NB_BIN return ServiceConfig( token=token, host=host, port=port, path=path, nb_env=nb_env, config_path=config_path, nb_bin=nb_bin, ) __all__ = [ "API_BASE_URL_ENV", "API_TOKEN_ENV", "DEFAULT_CONFIG_PATH", "DEFAULT_HOST", "DEFAULT_NB_BIN", "DEFAULT_NB_ENV", "DEFAULT_PATH", "DEFAULT_PORT", "DEFAULT_TOKEN_BYTES", "DEFAULT_UPLOAD_MAX_BYTES", "DEFAULT_UV_CACHE_DIR", "ENABLE_UPLOADS_ENV", "ENV_RELATIVE_PATH", "LEGACY_API_BASE_URL_ENV", "LEGACY_API_TOKEN_ENV", "MANAGED_ENV_NAMES", "SERVICE_NAME", "ServiceConfig", "ServicePaths", "_operator_env_lines", "_saved_uploads_setting", "find_uv_path", "read_service_env_file", "render_service_unit", "resolve_service_paths", "saved_service_config", "systemd_quote", "write_token_env_file", ]