refactor: simplify CRM MCP architecture

This commit is contained in:
nac
2026-08-15 01:46:34 +00:00
parent 8fe148ea8e
commit e4be04a4c3
34 changed files with 4922 additions and 4157 deletions
+91 -563
View File
@@ -1,570 +1,98 @@
#!/usr/bin/env python3
"""Manage the user-level 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 time
import urllib.error
import urllib.request
from collections.abc import Sequence
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",
}
from mcp_service_cli import main
from mcp_service_config import (
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,
find_uv_path,
read_service_env_file,
render_service_unit,
resolve_service_paths,
saved_service_config,
systemd_quote,
write_token_env_file,
)
from mcp_service_runtime import (
detect_remote_hosts,
find_nb_path,
generate_token,
new_token,
probe_http_service,
render_remote_config,
restart_service_if_installed,
run_systemctl,
service_state,
service_status,
show_config,
start_service,
stop_service,
uninstall_service,
wait_for_http_service,
write_service_unit,
)
@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 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 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) -> tuple[str, str, int, str, 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
nb_bin = values.get("NOCOBASE_MCP_NB_BIN") or DEFAULT_NB_BIN
return token, host, port, path, nb_env, config_path, nb_bin
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 "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:
token, host, port, path, _nb_env, _config_path, _nb_bin = saved_service_config(project_root)
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 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,
)
token, host, port, path, nb_env, config_path, _saved_nb_bin = saved_service_config(project_root)
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
nb_env=nb_env,
config_path=config_path,
nb_bin=nb_bin,
)
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 = wait_for_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')}")
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="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)
token, host, port, path, nb_env, _config_path, nb_bin = saved_service_config(project_root)
display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
lines.append(f"MCP URL: http://{display_host}:{port}{path}")
lines.append(f"nb binary: {nb_bin}")
lines.append(f"NocoBase CLI env: {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", 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 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 local 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 local 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}")
__all__ = [
"SERVICE_NAME",
"ENV_RELATIVE_PATH",
"DEFAULT_HOST",
"DEFAULT_PORT",
"DEFAULT_PATH",
"DEFAULT_NB_ENV",
"DEFAULT_CONFIG_PATH",
"DEFAULT_NB_BIN",
"DEFAULT_TOKEN_BYTES",
"DEFAULT_UV_CACHE_DIR",
"DEFAULT_UPLOAD_MAX_BYTES",
"ENABLE_UPLOADS_ENV",
"API_BASE_URL_ENV",
"API_TOKEN_ENV",
"LEGACY_API_BASE_URL_ENV",
"LEGACY_API_TOKEN_ENV",
"MANAGED_ENV_NAMES",
"ServicePaths",
"ServiceConfig",
"systemd_quote",
"find_uv_path",
"resolve_service_paths",
"render_service_unit",
"write_token_env_file",
"read_service_env_file",
"saved_service_config",
"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",
"main",
]
if __name__ == "__main__":