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
+26 -3
View File
@@ -62,7 +62,24 @@ NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1 uv run nocobase-crm-mcp --config config.json
The `owner` parameter on `crm_overview`, `list_customers`, and `list_followups` accepts a user's username, nickname, email, or English name. The server resolves that value through the `users` resource, then filters CRM relation fields by user id. If the value is missing or ambiguous, the tool returns a structured `error` object with candidate users instead of throwing an MCP error. Remote agents should call `resolve_owner` first when they are unsure which user the requester means.
Use `crm_filter_options` to discover status/source values and owner candidates before constructing filtered queries. List tools clamp `limit` to `1..100` and normalize negative offsets to `0`.
Use `crm_filter_options` to discover status/source values and owner candidates before constructing filtered queries. Status/source options are sorted by count descending, with blank source values last.
List tools clamp `limit` to `1..100` and normalize negative offsets to `0`. `list_customers`, `list_contacts`, and `list_followups` return compact rows by default so remote agents do not receive large nested NocoBase relation payloads. Pass `include_raw=true` only when the original NocoBase row is needed; the raw row is then attached under `raw`.
`list_followups` returns `content_preview` in compact mode. Use `include_raw=true` when the full follow-up text is needed. `get_customer` keeps the customer detail record intact, and its related follow-ups stay compact unless `include_raw_followups=true`.
Tool runtime failures are returned as structured errors:
```json
{
"error": {
"code": "nb_command_failed",
"message": "...",
"hint": "...",
"retryable": false
}
}
```
## Permissions
@@ -104,7 +121,13 @@ For a managed user-level systemd service:
bash scripts/mcp_service.sh start
```
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token automatically. Use `new-token` only when rotating the token later.
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token automatically. It also discovers the current `nb` executable and saves it as `NOCOBASE_MCP_NB_BIN`, because the systemd user service does not inherit an interactive shell or nvm PATH. Use `new-token` only when rotating the token later.
If `nb` is installed somewhere unusual, set its absolute path before starting:
```bash
NOCOBASE_MCP_NB_BIN=/absolute/path/to/nb bash scripts/mcp_service.sh start
```
Show the saved URL/header/client config:
@@ -112,7 +135,7 @@ Show the saved URL/header/client config:
bash scripts/mcp_service.sh show-config
```
Check service state, saved paths, URL, and local HTTP probe:
Check service state, saved paths, saved `nb` executable, URL, and local HTTP probe:
```bash
bash scripts/mcp_service.sh status
+212 -9
View File
@@ -16,6 +16,7 @@ from .nb_client import NbClient, NbCommandError
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
CONTENT_PREVIEW_CHARS = 500
CUSTOMER_STATUS_LABELS = {
"kh_p_not_start": "未开始",
@@ -183,7 +184,7 @@ class CrmService:
[self.config.customer.owner_field, "id"],
customer_filter,
),
"recent_followups": self._decorate_followups(
"recent_followups": self._compact_followups(
self._data(
self.client.list_records(
self.config.followup.resource,
@@ -242,6 +243,7 @@ class CrmService:
source: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
@@ -258,12 +260,14 @@ class CrmService:
response = self.client.list_records(
self.config.customer.resource,
filter=filter_value,
fields=self.config.customer.fields,
fields=self.config.customer.fields if include_raw else self._customer_compact_fields(),
sort=self.config.customer.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
return _paginated_response(response, limit, offset)
result = _paginated_response(response, limit, offset)
result["results"] = self._compact_customers(result["results"], include_raw=include_raw)
return result
def get_customer(
self,
@@ -271,6 +275,7 @@ class CrmService:
include_contacts: bool = True,
include_recent_followups: bool = True,
followup_limit: int = 10,
include_raw_followups: bool = False,
) -> dict[str, Any]:
customer = self._single(
self.client.get_record(
@@ -287,6 +292,7 @@ class CrmService:
customer_id=customer_id,
limit=followup_limit,
offset=0,
include_raw=include_raw_followups,
)["results"]
return result
@@ -296,6 +302,7 @@ class CrmService:
query: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
filter_value = build_and_filter(
@@ -307,12 +314,14 @@ class CrmService:
response = self.client.list_records(
self.config.contact.resource,
filter=filter_value,
fields=self.config.contact.fields,
fields=self.config.contact.fields if include_raw else self._contact_compact_fields(),
sort=self.config.contact.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
return _paginated_response(response, limit, offset)
result = _paginated_response(response, limit, offset)
result["results"] = self._compact_contacts(result["results"], include_raw=include_raw)
return result
def list_followups(
self,
@@ -323,6 +332,7 @@ class CrmService:
date_to: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
@@ -339,13 +349,13 @@ class CrmService:
response = self.client.list_records(
self.config.followup.resource,
filter=filter_value,
fields=self.config.followup.fields,
fields=self.config.followup.fields if include_raw else self._followup_compact_fields(),
sort=self.config.followup.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
result = _paginated_response(response, limit, offset)
result["results"] = self._decorate_followups(result["results"])
result["results"] = self._compact_followups(result["results"], include_raw=include_raw)
return result
def _safe_metadata(self) -> dict[str, Any]:
@@ -476,8 +486,154 @@ class CrmService:
decorated.append(item)
return decorated
def _decorate_followups(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{**row, "summary": self._followup_summary(row)} for row in rows]
def _compact_customers(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_customer(row, include_raw=include_raw) for row in rows]
def _customer_compact_fields(self) -> list[str]:
return _compact_field_list(
self.config.customer.fields,
[
self.config.customer.id_field,
"f_q2q60q7bqbg",
"name",
"title",
*(self.config.customer.search_fields or []),
"f_kh_english_name",
"english_name",
"code",
"f_territorial_name",
"territorial_name",
self.config.customer.status_field,
"kh_progress",
"status",
self.config.customer.source_field,
"f_225h6n6v654",
"source",
self.config.customer.owner_field,
"kehu_followup_person",
"owner",
"createdBy",
"createdAt",
"updatedAt",
],
)
def _compact_customer(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
status = _first_value(row, [self.config.customer.status_field, "kh_progress", "status"])
source = _first_value(row, [self.config.customer.source_field, "f_225h6n6v654", "source"])
compact: dict[str, Any] = {
"id": row.get(self.config.customer.id_field),
"name": _first_text(row, ["f_q2q60q7bqbg", "name", "title", *(self.config.customer.search_fields or [])]),
"english_name": _first_text(row, ["f_kh_english_name", "english_name", "code"]),
"territorial_name": _first_text(row, ["f_territorial_name", "territorial_name"]),
"status": status,
"status_display": CUSTOMER_STATUS_LABELS.get(status, str(status) if status is not None else None),
"source": source,
"owner_names": _relation_labels(
row,
[self.config.customer.owner_field, "kehu_followup_person", "owner", "createdBy"],
USER_LABEL_KEYS,
),
"createdAt": row.get("createdAt"),
"updatedAt": row.get("updatedAt"),
}
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_contacts(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_contact(row, include_raw=include_raw) for row in rows]
def _contact_compact_fields(self) -> list[str]:
return _compact_field_list(
self.config.contact.fields,
[
self.config.contact.id_field,
"f_2zpg91gtmwp",
"name",
*(self.config.contact.search_fields or []),
"khlxr_phone_number",
"phone",
"khlxr_email",
"email",
"f_tn8zdm2f938",
"title",
"position",
self.config.contact.customer_field,
"kehulianxiren_client",
"customer",
"client",
"createdAt",
"updatedAt",
],
)
def _compact_contact(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
compact: dict[str, Any] = {
"id": row.get(self.config.contact.id_field),
"name": _first_text(row, [*(self.config.contact.search_fields or []), "f_2zpg91gtmwp", "name", "title"]),
"phone": _first_text(row, ["khlxr_phone_number", "phone"]),
"email": _first_text(row, ["khlxr_email", "email"]),
"title": _first_text(row, ["f_tn8zdm2f938", "title", "position"]),
"customer_names": _relation_labels_first_available(
row,
[self.config.contact.customer_field, "kehulianxiren_client", "customer", "client"],
CUSTOMER_NAME_KEYS,
),
"createdAt": row.get("createdAt"),
"updatedAt": row.get("updatedAt"),
}
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_followups(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_followup(row, include_raw=include_raw) for row in rows]
def _followup_compact_fields(self) -> list[str]:
return _compact_field_list(
self.config.followup.fields,
[
self.config.followup.id_field,
self.config.followup.date_field,
"f_srysmjkwfq1",
"followedAt",
*(self.config.followup.search_fields or []),
*FOLLOWUP_CONTENT_KEYS,
self.config.followup.customer_field,
"gjjl_client",
"customer",
"client",
self.config.followup.contact_field,
"gjjl_client_contacts",
"contact",
"contacts",
*self.config.followup.owner_fields,
"gjjl_followup_person",
"createdBy",
"owner",
"createdAt",
"updatedAt",
],
)
def _compact_followup(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
summary = self._followup_summary(row)
content = summary["content"]
compact: dict[str, Any] = {
"id": row.get(self.config.followup.id_field),
"followedAt": summary["followedAt"],
"content_preview": _text_preview(content, CONTENT_PREVIEW_CHARS),
"content_truncated": isinstance(content, str) and len(content) > CONTENT_PREVIEW_CHARS,
"customer_names": summary["customer_names"],
"contact_names": summary["contact_names"],
"owner_names": summary["owner_names"],
"createdAt": row.get("createdAt"),
"updatedAt": row.get("updatedAt"),
}
if include_raw:
compact["raw"] = dict(row)
return compact
def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]:
content_fields = list(self.config.followup.search_fields or []) + list(FOLLOWUP_CONTENT_KEYS)
@@ -515,6 +671,16 @@ def _normalize_pagination(limit: Any, offset: Any) -> tuple[int, int]:
return min(max(1, _int_or_default(limit, DEFAULT_PAGE_SIZE)), MAX_PAGE_SIZE), max(0, _int_or_default(offset, 0))
def _compact_field_list(configured_fields: list[str] | None, candidates: list[str]) -> list[str]:
available = set(configured_fields or [])
selected: list[str] = []
for candidate in _unique_strings(candidates):
if available and candidate not in available:
continue
selected.append(candidate)
return selected
def _int_or_default(value: Any, default: int) -> int:
try:
return int(value)
@@ -593,6 +759,7 @@ def _decorate_dimension_counts(
item["label"] = label
item[display_key] = label
decorated.append(item)
decorated.sort(key=lambda item: (_is_blank(item.get("value")), -_count_value(item), str(item.get("label") or "")))
return decorated
@@ -608,6 +775,25 @@ def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple
return None, None
def _count_value(row: Mapping[str, Any]) -> int:
value = row.get("count")
if isinstance(value, (int, float)):
return int(value)
return 0
def _is_blank(value: Any) -> bool:
return value is None or str(value).strip() == ""
def _first_value(row: Mapping[str, Any], fields: list[str]) -> Any:
for field in _unique_strings(fields):
value = row.get(field)
if value is not None and value != "":
return value
return None
def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
for field in _unique_strings(fields):
value = row.get(field)
@@ -619,6 +805,14 @@ def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
return None
def _text_preview(value: str | None, max_chars: int) -> str | None:
if value is None:
return None
if len(value) <= max_chars:
return value
return value[: max(0, max_chars - 3)].rstrip() + "..."
def _relation_labels(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]:
labels: list[str] = []
for field in _unique_strings(fields):
@@ -626,6 +820,15 @@ def _relation_labels(row: Mapping[str, Any], fields: list[str], label_keys: tupl
return labels
def _relation_labels_first_available(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]:
for field in _unique_strings(fields):
labels: list[str] = []
_append_relation_labels(labels, row.get(field), label_keys)
if labels:
return labels
return []
def _append_relation_labels(labels: list[str], value: Any, label_keys: tuple[str, ...]) -> None:
if value is None or value == "":
return
+14 -1
View File
@@ -29,7 +29,8 @@ class NbClient:
) -> None:
self.env_name = env_name
self.data_source = data_source
self.nb_bin = nb_bin
expanded_nb_bin = Path(nb_bin).expanduser()
self.nb_bin = str(expanded_nb_bin) if str(nb_bin).startswith("~") or expanded_nb_bin.is_absolute() else nb_bin
self.api_token = api_token
self.role = role
self.cwd = Path(cwd).resolve() if cwd else None
@@ -212,6 +213,7 @@ class NbClient:
env = os.environ.copy()
env["NB_CLI_LOG_DISABLED"] = "1"
env["NB_SKIP_STARTUP_UPDATE"] = "1"
self._prepend_nb_bin_dir_to_path(env)
return self.runner(
command,
cwd=str(self.cwd) if self.cwd else None,
@@ -222,6 +224,17 @@ class NbClient:
check=False,
)
def _prepend_nb_bin_dir_to_path(self, env: dict[str, str]) -> None:
nb_path = Path(self.nb_bin).expanduser()
if not nb_path.is_absolute():
return
nb_dir = str(nb_path.parent)
existing_path = env.get("PATH", "")
parts = [part for part in existing_path.split(os.pathsep) if part]
if nb_dir in parts:
return
env["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "")
def _format_error(self, command: list[str], result: subprocess.CompletedProcess[str]) -> str:
return (
f"nb command failed with exit code {result.returncode}: {self._redact_command(command)}\n"
+97 -27
View File
@@ -3,8 +3,9 @@ from __future__ import annotations
import argparse
import hmac
import os
import subprocess
from pathlib import Path
from typing import Annotated, Any
from typing import Annotated, Any, Callable
try:
from pydantic import Field
@@ -23,7 +24,7 @@ except ModuleNotFoundError:
from .config import load_config
from .crm import CrmService
from .nb_client import NbClient
from .nb_client import NbClient, NbCommandError
DEFAULT_HTTP_HOST = "0.0.0.0"
@@ -89,10 +90,48 @@ class BearerTokenAuthMiddleware:
def _make_service(config_path: str | Path | None = None) -> CrmService:
config = load_config(config_path)
client = NbClient(env_name=config.env_name, data_source=config.data_source)
nb_bin = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip() or "nb"
client = NbClient(env_name=config.env_name, data_source=config.data_source, nb_bin=nb_bin)
return CrmService(config, client)
def _safe_tool_result(operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
try:
return operation()
except NbCommandError as error:
return _tool_error(
code="nb_command_failed",
message=str(error),
hint="Check nb env status, NocoBase ACL, collection names, and query parameters.",
retryable=False,
)
except subprocess.TimeoutExpired as error:
return _tool_error(
code="nb_command_timeout",
message=str(error),
hint="The NocoBase CLI call timed out. Retry with a smaller query or check NocoBase responsiveness.",
retryable=True,
)
except FileNotFoundError as error:
return _tool_error(
code="nb_not_found",
message=str(error),
hint="Set NOCOBASE_MCP_NB_BIN to the absolute nb path, then restart with scripts/mcp_service.sh start.",
retryable=False,
)
except Exception as error:
return _tool_error(
code="tool_error",
message=str(error),
hint="Check MCP server logs and the CRM mapping config.",
retryable=False,
)
def _tool_error(*, code: str, message: str, hint: str, retryable: bool) -> dict[str, Any]:
return {"error": {"code": code, "message": message, "hint": hint, "retryable": retryable}}
def create_server(
*,
crm_service: CrmService | None = None,
@@ -125,7 +164,7 @@ def create_server(
@server.tool()
def crm_health() -> dict[str, Any]:
"""Check NocoBase CLI reachability and the configured CRM collection mapping."""
return service.crm_health()
return _safe_tool_result(service.crm_health)
@server.tool()
def crm_schema(
@@ -135,7 +174,7 @@ def create_server(
] = False,
) -> dict[str, Any]:
"""Return the configured CRM collection and field mapping for this MCP server."""
return service.crm_schema(include_fields=include_fields)
return _safe_tool_result(lambda: service.crm_schema(include_fields=include_fields))
@server.tool()
def crm_overview(
@@ -153,7 +192,7 @@ def create_server(
] = None,
) -> dict[str, Any]:
"""Return CRM totals, common customer distributions, and recent follow-ups."""
return service.crm_overview(date_from=date_from, date_to=date_to, owner=owner)
return _safe_tool_result(lambda: service.crm_overview(date_from=date_from, date_to=date_to, owner=owner))
@server.tool()
def crm_filter_options(
@@ -163,21 +202,21 @@ def create_server(
] = None,
) -> dict[str, Any]:
"""Return available customer filter values and optional owner candidates."""
return service.crm_filter_options(owner_query=owner_query)
return _safe_tool_result(lambda: service.crm_filter_options(owner_query=owner_query))
@server.tool()
def resolve_owner(
query: Annotated[str, Field(description="Owner username, nickname, email, or English name to resolve.")],
) -> dict[str, Any]:
"""Return candidate NocoBase users for an owner filter before querying CRM data."""
return service.resolve_owner(query)
return _safe_tool_result(lambda: service.resolve_owner(query))
if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
@server.tool()
def crm_config_template() -> dict[str, Any]:
"""Infer a config.json template from collection field metadata without reading business records."""
return service.crm_config_template()
return _safe_tool_result(service.crm_config_template)
@server.tool()
def list_customers(
@@ -187,9 +226,20 @@ def create_server(
source: Annotated[str | None, Field(description="Optional exact customer source.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False,
) -> dict[str, Any]:
"""List CRM customers with pagination metadata."""
return service.list_customers(query=query, status=status, owner=owner, source=source, limit=limit, offset=offset)
"""List CRM customers with compact rows and pagination metadata."""
return _safe_tool_result(
lambda: service.list_customers(
query=query,
status=status,
owner=owner,
source=source,
limit=limit,
offset=offset,
include_raw=include_raw,
)
)
@server.tool()
def get_customer(
@@ -197,13 +247,20 @@ def create_server(
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = True,
followup_limit: Annotated[int, Field(description="Maximum related follow-ups to return.")] = 10,
include_raw_followups: Annotated[
bool,
Field(description="When true, include original NocoBase follow-up rows under raw. Defaults to false."),
] = False,
) -> dict[str, Any]:
"""Get one customer and optionally include contacts and recent follow-ups."""
return service.get_customer(
customer_id,
include_contacts=include_contacts,
include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
return _safe_tool_result(
lambda: service.get_customer(
customer_id,
include_contacts=include_contacts,
include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
include_raw_followups=include_raw_followups,
)
)
@server.tool()
@@ -212,9 +269,18 @@ def create_server(
query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False,
) -> dict[str, Any]:
"""List CRM contacts, optionally filtered by customer."""
return service.list_contacts(customer_id=customer_id, query=query, limit=limit, offset=offset)
"""List CRM contacts as compact rows, optionally filtered by customer."""
return _safe_tool_result(
lambda: service.list_contacts(
customer_id=customer_id,
query=query,
limit=limit,
offset=offset,
include_raw=include_raw,
)
)
@server.tool()
def list_followups(
@@ -225,16 +291,20 @@ def create_server(
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False,
) -> dict[str, Any]:
"""List customer follow-up records with pagination metadata."""
return service.list_followups(
customer_id=customer_id,
contact_id=contact_id,
owner=owner,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
"""List customer follow-up records as compact rows with pagination metadata."""
return _safe_tool_result(
lambda: service.list_followups(
customer_id=customer_id,
contact_id=contact_id,
owner=owner,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
include_raw=include_raw,
)
)
return server
+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')})")
+96 -15
View File
@@ -32,17 +32,58 @@ class FakeNbClient:
],
"meta": {"count": 1},
}
if resource == "contacts":
return {
"data": [
{
"id": 2,
"name": "Alice Contact",
"phone": "+1 555 0100",
"email": "alice.contact@example.com",
"title": "Buyer",
"customer": [{"id": 1, "name": "Acme"}],
"kehulianxiren_client": [{"id": 1, "f_q2q60q7bqbg": "Acme China"}],
"createdAt": "2026-07-01T00:00:00.000Z",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
],
"meta": {"count": 1},
}
if resource == "followups":
return {
"data": [
{
"id": 1,
"f_srysmjkwfq1": "2026-07-08T09:00:00.000Z",
"f_19iplxz0nds": "Called customer about sample status",
"f_19iplxz0nds": "Called customer about sample status and confirmed next procurement meeting.",
"gjjl_client": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
"gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
"gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"createdBy": {"id": 54, "nickname": "Alice Wang"},
"createdAt": "2026-07-08T09:10:00.000Z",
"updatedAt": "2026-07-08T09:20:00.000Z",
}
],
"meta": {"count": 1},
}
if resource == "customers":
return {
"data": [
{
"id": 1,
"name": "Acme",
"f_q2q60q7bqbg": "Acme China",
"f_kh_english_name": "Acme",
"f_territorial_name": "US",
"status": "kh_p_inprogress",
"kh_progress": "kh_p_inprogress",
"source": "客户推荐",
"f_225h6n6v654": "客户推荐",
"owner": [{"id": 54, "nickname": "Alice Wang"}],
"kehu_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"kehu_contacts": [{"id": 99, "name": "Nested contact that should stay out of compact results"}],
"createdAt": "2026-07-01T00:00:00.000Z",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
],
"meta": {"count": 1},
@@ -58,9 +99,20 @@ class FakeNbClient:
dimensions = kwargs.get("dimensions") or []
alias = dimensions[0].get("alias") if dimensions else None
if alias in {"status", "kh_progress"}:
return {"data": [{"count": 3, "kh_progress": "kh_p_inprogress"}]}
return {
"data": [
{"count": 3, "kh_progress": "kh_p_inprogress"},
{"count": 9, "kh_progress": "kh_p_ordered"},
]
}
if alias in {"source", "f_225h6n6v654"}:
return {"data": [{"count": 2, "f_225h6n6v654": "客户推荐"}]}
return {
"data": [
{"count": 1, "f_225h6n6v654": "https://example.com"},
{"count": 9, "f_225h6n6v654": "客户推荐"},
{"count": 2, "f_225h6n6v654": ""},
]
}
if alias == "owner_id":
return {"data": [{"count": 1, "owner_id": 54}]}
if alias == "kehu_followup_person_id":
@@ -111,7 +163,12 @@ class CrmServiceTests(unittest.TestCase):
result = service.list_customers(query="Acme", status="active", owner="Alice", limit=10, offset=20)
self.assertEqual(result["results"], [{"id": 1, "name": "Acme"}])
self.assertEqual(result["results"][0]["id"], 1)
self.assertEqual(result["results"][0]["name"], "Acme China")
self.assertEqual(result["results"][0]["status_display"], "进行中")
self.assertEqual(result["results"][0]["owner_names"], ["Alice Wang"])
self.assertNotIn("kehu_contacts", result["results"][0])
self.assertNotIn("raw", result["results"][0])
self.assertEqual(result["total"], 1)
self.assertEqual(client.list_calls[0][0], "users")
resource, kwargs = client.list_calls[1]
@@ -143,9 +200,14 @@ class CrmServiceTests(unittest.TestCase):
self.assertEqual(result["customer"]["id"], 7)
self.assertEqual(result["customer"]["name"], "Acme")
self.assertEqual(result["contacts"], [{"id": 1, "name": "Acme"}])
self.assertEqual(result["contacts"][0]["id"], 2)
self.assertEqual(result["contacts"][0]["customer_names"], ["Acme"])
self.assertEqual(result["recent_followups"][0]["id"], 1)
self.assertEqual(result["recent_followups"][0]["summary"]["content"], "Called customer about sample status")
self.assertEqual(
result["recent_followups"][0]["content_preview"],
"Called customer about sample status and confirmed next procurement meeting.",
)
self.assertNotIn("f_19iplxz0nds", result["recent_followups"][0])
self.assertEqual(client.get_calls[0][0], "customers")
self.assertEqual(client.list_calls[0][0], "contacts")
self.assertEqual(client.list_calls[0][1]["filter"], {"$and": [{"customer": {"id": {"$eq": 7}}}]})
@@ -161,7 +223,8 @@ class CrmServiceTests(unittest.TestCase):
self.assertEqual(result["totals"]["customers"], 3)
self.assertEqual(result["totals"]["contacts"], 3)
self.assertEqual(result["totals"]["followups"], 3)
self.assertEqual(result["customer_status"][0]["display"], "进行中")
self.assertEqual(result["customer_status"][0]["display"], "已下单")
self.assertEqual(result["recent_followups"][0]["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
self.assertEqual(result["customer_owner"][0]["owner_id"], 54)
self.assertGreaterEqual(len(client.query_calls), 3)
self.assertEqual(client.query_calls[0][0], "customers")
@@ -299,11 +362,19 @@ class CrmServiceTests(unittest.TestCase):
client = FakeNbClient()
result = CrmService(load_config(None), client).crm_filter_options(owner_query="alice")
self.assertEqual(result["customer_status"][0]["value"], "kh_p_inprogress")
self.assertEqual(result["customer_status"][0]["label"], "进行中")
self.assertEqual(result["customer_status"][0]["value"], "kh_p_ordered")
self.assertEqual(result["customer_status"][0]["label"], "已下单")
self.assertEqual(result["customer_source"][0]["value"], "客户推荐")
self.assertEqual(result["customer_source"][-1]["value"], "")
self.assertEqual(result["owner_candidates"][0]["id"], 54)
def test_list_customers_can_opt_into_raw_rows(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_customers(limit=1, include_raw=True)
row = result["results"][0]
self.assertEqual(row["name"], "Acme China")
self.assertEqual(row["raw"]["kehu_contacts"][0]["id"], 99)
def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
@@ -315,15 +386,25 @@ class CrmServiceTests(unittest.TestCase):
self.assertEqual(client.list_calls[0][1]["page_size"], 100)
self.assertEqual(client.list_calls[0][1]["page"], 1)
def test_list_followups_adds_human_readable_summary_without_dropping_raw_fields(self) -> None:
def test_list_followups_returns_compact_preview_by_default(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1)
row = result["results"][0]
self.assertEqual(row["f_19iplxz0nds"], "Called customer about sample status")
self.assertEqual(row["summary"]["content"], "Called customer about sample status")
self.assertEqual(row["summary"]["followedAt"], "2026-07-08T09:00:00.000Z")
self.assertEqual(row["summary"]["customer_names"], ["Acme China"])
self.assertEqual(row["summary"]["owner_names"], ["Alice Wang"])
self.assertEqual(row["id"], 1)
self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
self.assertEqual(row["followedAt"], "2026-07-08T09:00:00.000Z")
self.assertEqual(row["customer_names"], ["Acme China"])
self.assertEqual(row["owner_names"], ["Alice Wang"])
self.assertNotIn("f_19iplxz0nds", row)
self.assertNotIn("summary", row)
def test_list_followups_can_opt_into_raw_rows(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1, include_raw=True)
row = result["results"][0]
self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
self.assertEqual(row["raw"]["f_19iplxz0nds"], "Called customer about sample status and confirmed next procurement meeting.")
self.assertEqual(row["raw"]["gjjl_client"][0]["f_q2q60q7bqbg"], "Acme China")
if __name__ == "__main__":
+82
View File
@@ -63,6 +63,7 @@ class McpServiceConfigTests(unittest.TestCase):
path="/mcp",
nb_env="nb-mcp",
config_path="config.json",
nb_bin="/opt/node/bin/nb",
)
content = env_path.read_text(encoding="utf-8")
@@ -72,9 +73,50 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("NOCOBASE_MCP_PATH=/mcp\n", content)
self.assertIn("NOCOBASE_MCP_ENV=nb-mcp\n", content)
self.assertIn("NOCOBASE_MCP_CONFIG=config.json\n", content)
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", content)
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
def test_find_nb_path_uses_env_or_path_and_errors_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
nb_bin = Path(tmp) / "bin" / "nb"
nb_bin.parent.mkdir()
nb_bin.write_text("#!/bin/sh\n", encoding="utf-8")
nb_bin.chmod(0o755)
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": str(nb_bin)}):
self.assertEqual(manage_mcp_service.find_nb_path(), Path(os.path.abspath(nb_bin)))
cli_script = Path(tmp) / "lib" / "node_modules" / "@nocobase" / "cli" / "bin" / "run.js"
cli_script.parent.mkdir(parents=True)
cli_script.write_text("#!/usr/bin/env node\n", encoding="utf-8")
cli_script.chmod(0o755)
nb_link = Path(tmp) / "node" / "bin" / "nb"
nb_link.parent.mkdir(parents=True)
nb_link.symlink_to(cli_script)
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": str(nb_link)}):
self.assertEqual(manage_mcp_service.find_nb_path(), nb_link)
missing_nb = Path(tmp) / "missing" / "nb"
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": str(missing_nb)}):
with self.assertRaisesRegex(RuntimeError, "not an executable"):
manage_mcp_service.find_nb_path()
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "nb-custom"}), patch.object(
manage_mcp_service.shutil,
"which",
return_value="/usr/local/bin/nb-custom",
):
self.assertEqual(manage_mcp_service.find_nb_path(), Path("/usr/local/bin/nb-custom"))
with patch.dict("os.environ", {}, clear=True), patch.object(manage_mcp_service.shutil, "which", return_value="/usr/local/bin/nb"):
self.assertEqual(manage_mcp_service.find_nb_path(), Path("/usr/local/bin/nb"))
with patch.dict("os.environ", {}, clear=True), patch.object(manage_mcp_service.shutil, "which", return_value=None):
with self.assertRaisesRegex(RuntimeError, "Could not find nb"):
manage_mcp_service.find_nb_path()
def test_render_remote_config_prints_url_and_authorization_header(self) -> None:
output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10"],
@@ -100,6 +142,7 @@ class McpServiceConfigTests(unittest.TestCase):
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
):
output = manage_mcp_service.new_token(project_root=project_root)
@@ -108,6 +151,7 @@ class McpServiceConfigTests(unittest.TestCase):
env_content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", env_content)
self.assertIn("NOCOBASE_MCP_PORT=10087\n", env_content)
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", env_content)
self.assertIn("http://10.0.0.2:10087/mcp", output)
run_systemctl.assert_not_called()
@@ -129,6 +173,7 @@ class McpServiceConfigTests(unittest.TestCase):
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
patch.object(
manage_mcp_service,
@@ -141,8 +186,10 @@ class McpServiceConfigTests(unittest.TestCase):
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
unit_content = unit_path.read_text(encoding="utf-8")
env_content = (project_root / ".runtime" / "nocobase-crm-mcp.env").read_text(encoding="utf-8")
self.assertIn("/home/nac/.local/bin/uv run --no-sync nocobase-crm-mcp", unit_content)
self.assertIn("EnvironmentFile=", unit_content)
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", env_content)
self.assertIn("http://10.0.0.2:10087/mcp", output)
self.assertIn("Bearer saved-token", output)
run_systemctl.assert_has_calls(
@@ -165,6 +212,7 @@ class McpServiceConfigTests(unittest.TestCase):
patch.object(manage_mcp_service, "generate_token", return_value="first-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
patch.object(
manage_mcp_service,
@@ -177,6 +225,7 @@ class McpServiceConfigTests(unittest.TestCase):
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
self.assertIn("NOCOBASE_MCP_TOKEN=first-token\n", env_path.read_text(encoding="utf-8"))
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", env_path.read_text(encoding="utf-8"))
self.assertIn("Bearer first-token", output)
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
self.assertTrue(unit_path.exists())
@@ -190,6 +239,38 @@ class McpServiceConfigTests(unittest.TestCase):
)
probe_http_service.assert_called_once_with("127.0.0.1", 10087, "/mcp", token="first-token")
def test_start_service_waits_for_http_probe_after_systemd_is_active(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl"),
patch.object(manage_mcp_service.time, "sleep") as sleep,
patch.object(
manage_mcp_service,
"probe_http_service",
side_effect=[
{"reachable": False, "error": "connection refused"},
{"reachable": True, "status": 406},
],
create=True,
) as probe_http_service,
):
output = manage_mcp_service.start_service(project_root=project_root)
self.assertIn("Service status: active", output)
self.assertEqual(probe_http_service.call_count, 2)
sleep.assert_called_once()
def test_start_service_fails_when_systemd_does_not_become_active(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
@@ -208,6 +289,7 @@ class McpServiceConfigTests(unittest.TestCase):
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl", side_effect=fake_systemctl),
):
with self.assertRaisesRegex(RuntimeError, "did not become active"):
+13
View File
@@ -86,6 +86,19 @@ class NbClientTests(unittest.TestCase):
self.assertIn("--orders", commands[1])
self.assertIn("--timezone", commands[1])
def test_absolute_nb_bin_prepends_its_directory_to_subprocess_path(self) -> None:
calls: list[dict] = []
def runner(cmd, *, env, **kwargs):
calls.append({"cmd": cmd, "env": env})
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True):
NbClient(env_name="nb-mcp", nb_bin="/opt/node/bin/nb", runner=runner).list_records("customers")
self.assertEqual(calls[0]["cmd"][0], "/opt/node/bin/nb")
self.assertEqual(calls[0]["env"]["PATH"], "/opt/node/bin:/usr/bin")
def test_list_collection_fields_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = []
+59
View File
@@ -2,6 +2,8 @@ import asyncio
import unittest
from unittest.mock import patch
from nocobase_crm_mcp import server as server_module
from nocobase_crm_mcp.nb_client import NbCommandError
from nocobase_crm_mcp.server import BearerTokenAuthMiddleware, FastMCP, create_http_app, create_server
@@ -59,6 +61,16 @@ class FakeCrmService:
return {"results": []}
class ErrorCrmService(FakeCrmService):
def list_customers(self, **kwargs):
raise NbCommandError("nb command failed with exit code 1: denied")
class MissingNbCrmService(FakeCrmService):
def crm_health(self):
raise FileNotFoundError(2, "No such file or directory", "nb")
class ServerTests(unittest.TestCase):
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
@@ -103,6 +115,53 @@ class ServerTests(unittest.TestCase):
},
)
def test_tool_schemas_expose_raw_opt_in_parameters(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=FakeCrmService(), readonly=True)
tools = {tool.name: tool for tool in asyncio.run(server.list_tools())}
self.assertIn("include_raw", tools["list_customers"].inputSchema["properties"])
self.assertIn("include_raw", tools["list_contacts"].inputSchema["properties"])
self.assertIn("include_raw", tools["list_followups"].inputSchema["properties"])
self.assertIn("include_raw_followups", tools["get_customer"].inputSchema["properties"])
def test_tools_return_structured_error_for_nb_failures(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=ErrorCrmService(), readonly=True)
_content, structured = asyncio.run(server.call_tool("list_customers", {}))
self.assertEqual(structured["error"]["code"], "nb_command_failed")
self.assertIn("denied", structured["error"]["message"])
self.assertFalse(structured["error"]["retryable"])
def test_tools_return_specific_error_when_nb_is_not_found(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=MissingNbCrmService(), readonly=True)
_content, structured = asyncio.run(server.call_tool("crm_health", {}))
self.assertEqual(structured["error"]["code"], "nb_not_found")
self.assertIn("NOCOBASE_MCP_NB_BIN", structured["error"]["hint"])
self.assertFalse(structured["error"]["retryable"])
def test_make_service_uses_nb_bin_from_environment(self) -> None:
with (
patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "/opt/node/bin/nb"}),
patch.object(server_module, "NbClient") as nb_client,
patch.object(server_module, "CrmService", side_effect=lambda config, client: {"config": config, "client": client}),
):
service = server_module._make_service()
self.assertIs(service["client"], nb_client.return_value)
nb_client.assert_called_once_with(env_name="nb-mcp", data_source="main", nb_bin="/opt/node/bin/nb")
def test_config_template_tool_is_opt_in(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")