feat: improve CRM MCP reliability

This commit is contained in:
nac
2026-07-13 02:56:36 +00:00
parent 3be6dee45e
commit dd129a90a4
9 changed files with 666 additions and 62 deletions
+67 -7
View File
@@ -10,6 +10,7 @@ import shlex
import shutil
import socket
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
@@ -24,6 +25,7 @@ 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"
@@ -51,6 +53,20 @@ def find_uv_path() -> 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"))
@@ -114,6 +130,7 @@ def write_token_env_file(
path: str = DEFAULT_PATH,
nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH,
nb_bin: str | Path = DEFAULT_NB_BIN,
) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.parent.chmod(0o700)
@@ -125,6 +142,7 @@ def write_token_env_file(
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_path.chmod(0o600)
@@ -145,7 +163,7 @@ def read_service_env_file(env_path: Path) -> dict[str, str]:
return values
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, str]:
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()
@@ -156,7 +174,8 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, s
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
return token, host, port, path, nb_env, 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:
@@ -228,7 +247,7 @@ def render_remote_config(
def show_config(project_root: Path) -> str:
token, _host, port, path, _nb_env, _config_path = saved_service_config(project_root)
token, _host, port, path, _nb_env, _config_path, _nb_bin = saved_service_config(project_root)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
@@ -269,6 +288,25 @@ def probe_http_service(host: str, port: int, path: str, *, token: str, timeout:
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
@@ -288,13 +326,23 @@ def new_token(
) -> 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)
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)
return render_remote_config(hosts=detect_remote_hosts(), 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,
@@ -304,8 +352,19 @@ def start_service(*, project_root: Path) -> str:
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_service_config(project_root)
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])
@@ -314,7 +373,7 @@ def start_service(*, project_root: Path) -> str:
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 = probe_http_service("127.0.0.1", port, path, token=token)
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')}")
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status="active")
@@ -348,10 +407,11 @@ def service_status(*, project_root: Path) -> str:
f"Env file: {paths.env_path}",
]
if paths.env_path.exists():
token, _host, port, path, _nb_env, _config_path = saved_service_config(project_root)
token, _host, port, path, _nb_env, _config_path, nb_bin = saved_service_config(project_root)
hosts = detect_remote_hosts()
host = hosts[0] if hosts else "<server-ip>"
lines.append(f"MCP URL: http://{host}:{port}{path}")
lines.append(f"nb binary: {nb_bin}")
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')})")