feat: use each user's local NocoBase login

This commit is contained in:
nac
2026-08-14 11:33:14 +00:00
parent 76e4cbf58a
commit 51d1493a2f
20 changed files with 1561 additions and 315 deletions
+38 -22
View File
@@ -13,14 +13,13 @@ import subprocess
import time
import urllib.error
import urllib.request
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
SERVICE_NAME = "nocobase-crm-mcp.service"
ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 10087
DEFAULT_PATH = "/mcp"
DEFAULT_NB_ENV = "nb-mcp"
@@ -80,7 +79,9 @@ def find_nb_path() -> 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.")
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())))
@@ -213,9 +214,13 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, s
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:
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)
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")
@@ -250,7 +255,7 @@ def render_remote_config(
path: str = DEFAULT_PATH,
service_status: str | None = None,
) -> str:
host = hosts[0] if hosts else "<server-ip>"
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 ""
@@ -259,7 +264,7 @@ def render_remote_config(
"Saved MCP client config.\n"
"\n"
f"{status_section}"
"Use this URL from another machine:\n"
"Use this loopback URL on this machine:\n"
f"{url}\n"
f"{alternatives_section}"
"\n"
@@ -282,9 +287,10 @@ def render_remote_config(
def show_config(project_root: Path) -> str:
token, _host, port, path, _nb_env, _config_path, _nb_bin = saved_service_config(project_root)
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=detect_remote_hosts(),
hosts=[display_host],
token=token,
port=port,
path=path,
@@ -372,7 +378,8 @@ def new_token(
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())
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:
@@ -411,7 +418,8 @@ def start_service(*, project_root: Path) -> str:
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")
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:
@@ -443,11 +451,11 @@ def service_status(*, project_root: Path) -> str:
]
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)
hosts = detect_remote_hosts()
host = hosts[0] if hosts else "<server-ip>"
lines.append(f"MCP URL: http://{host}:{port}{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} (authenticate with `nb env auth {nb_env}`)")
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:
@@ -460,8 +468,8 @@ def service_status(*, project_root: Path) -> str:
):
credential_state = "incomplete"
else:
credential_state = "not configured"
lines.append(f"NocoBase API credentials: {credential_state}")
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')})")
@@ -477,7 +485,9 @@ 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 remote MCP client configuration.")
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)
@@ -486,15 +496,21 @@ def main(argv: Sequence[str] | None = None) -> int:
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 = 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 = 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 remote MCP client configuration.")
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.")