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