355 lines
12 KiB
Python
355 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
from mcp_service_config import (
|
|
API_BASE_URL_ENV,
|
|
API_TOKEN_ENV,
|
|
DEFAULT_CONFIG_PATH,
|
|
DEFAULT_HOST,
|
|
DEFAULT_NB_ENV,
|
|
DEFAULT_PATH,
|
|
DEFAULT_PORT,
|
|
DEFAULT_TOKEN_BYTES,
|
|
ENABLE_UPLOADS_ENV,
|
|
LEGACY_API_BASE_URL_ENV,
|
|
LEGACY_API_TOKEN_ENV,
|
|
SERVICE_NAME,
|
|
ServicePaths,
|
|
find_uv_path,
|
|
read_service_env_file,
|
|
render_service_unit,
|
|
resolve_service_paths,
|
|
saved_service_config,
|
|
write_token_env_file,
|
|
)
|
|
|
|
|
|
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
|
|
return secrets.token_urlsafe(num_bytes)
|
|
|
|
|
|
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 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,
|
|
uv_path=find_uv_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:
|
|
config = saved_service_config(project_root)
|
|
display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host
|
|
return render_remote_config(
|
|
hosts=[display_host],
|
|
token=config.token,
|
|
port=config.port,
|
|
path=config.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,
|
|
)
|
|
config = saved_service_config(project_root)
|
|
write_token_env_file(
|
|
paths.env_path,
|
|
config.token,
|
|
host=config.host,
|
|
port=config.port,
|
|
path=config.path,
|
|
nb_env=config.nb_env,
|
|
config_path=config.config_path,
|
|
nb_bin=nb_bin,
|
|
)
|
|
write_service_unit(paths, host=config.host, port=config.port, path=config.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", config.port, config.path, token=config.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 config.host in {"0.0.0.0", "::"} else config.host
|
|
return render_remote_config(
|
|
hosts=[display_host],
|
|
token=config.token,
|
|
port=config.port,
|
|
path=config.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)
|
|
config = saved_service_config(project_root)
|
|
display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host
|
|
lines.append(f"MCP URL: http://{display_host}:{config.port}{config.path}")
|
|
lines.append(f"nb binary: {config.nb_bin}")
|
|
lines.append(f"NocoBase CLI env: {config.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", config.port, config.path, token=config.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"
|
|
|
|
|
|
__all__ = [
|
|
"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",
|
|
]
|