265 lines
7.7 KiB
Python
265 lines
7.7 KiB
Python
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",
|
|
]
|