feat: improve CRM MCP reliability
This commit is contained in:
@@ -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')})")
|
||||
|
||||
Reference in New Issue
Block a user