refactor: simplify CRM MCP architecture
This commit is contained in:
+91
-563
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user