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. 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 ## Permissions
@@ -104,7 +121,13 @@ For a managed user-level systemd service:
bash scripts/mcp_service.sh start 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: 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 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
bash scripts/mcp_service.sh status bash scripts/mcp_service.sh status
+212 -9
View File
@@ -16,6 +16,7 @@ from .nb_client import NbClient, NbCommandError
DEFAULT_PAGE_SIZE = 20 DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
CONTENT_PREVIEW_CHARS = 500
CUSTOMER_STATUS_LABELS = { CUSTOMER_STATUS_LABELS = {
"kh_p_not_start": "未开始", "kh_p_not_start": "未开始",
@@ -183,7 +184,7 @@ class CrmService:
[self.config.customer.owner_field, "id"], [self.config.customer.owner_field, "id"],
customer_filter, customer_filter,
), ),
"recent_followups": self._decorate_followups( "recent_followups": self._compact_followups(
self._data( self._data(
self.client.list_records( self.client.list_records(
self.config.followup.resource, self.config.followup.resource,
@@ -242,6 +243,7 @@ class CrmService:
source: str | None = None, source: str | None = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset) limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner) owner_id, owner_error = self._resolve_owner_id_or_error(owner)
@@ -258,12 +260,14 @@ class CrmService:
response = self.client.list_records( response = self.client.list_records(
self.config.customer.resource, self.config.customer.resource,
filter=filter_value, 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, sort=self.config.customer.sort,
page=_page_from_offset(limit, offset), page=_page_from_offset(limit, offset),
page_size=limit, 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( def get_customer(
self, self,
@@ -271,6 +275,7 @@ class CrmService:
include_contacts: bool = True, include_contacts: bool = True,
include_recent_followups: bool = True, include_recent_followups: bool = True,
followup_limit: int = 10, followup_limit: int = 10,
include_raw_followups: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
customer = self._single( customer = self._single(
self.client.get_record( self.client.get_record(
@@ -287,6 +292,7 @@ class CrmService:
customer_id=customer_id, customer_id=customer_id,
limit=followup_limit, limit=followup_limit,
offset=0, offset=0,
include_raw=include_raw_followups,
)["results"] )["results"]
return result return result
@@ -296,6 +302,7 @@ class CrmService:
query: str | None = None, query: str | None = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset) limit, offset = _normalize_pagination(limit, offset)
filter_value = build_and_filter( filter_value = build_and_filter(
@@ -307,12 +314,14 @@ class CrmService:
response = self.client.list_records( response = self.client.list_records(
self.config.contact.resource, self.config.contact.resource,
filter=filter_value, 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, sort=self.config.contact.sort,
page=_page_from_offset(limit, offset), page=_page_from_offset(limit, offset),
page_size=limit, 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( def list_followups(
self, self,
@@ -323,6 +332,7 @@ class CrmService:
date_to: str | None = None, date_to: str | None = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset) limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner) owner_id, owner_error = self._resolve_owner_id_or_error(owner)
@@ -339,13 +349,13 @@ class CrmService:
response = self.client.list_records( response = self.client.list_records(
self.config.followup.resource, self.config.followup.resource,
filter=filter_value, 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, sort=self.config.followup.sort,
page=_page_from_offset(limit, offset), page=_page_from_offset(limit, offset),
page_size=limit, page_size=limit,
) )
result = _paginated_response(response, limit, offset) 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 return result
def _safe_metadata(self) -> dict[str, Any]: def _safe_metadata(self) -> dict[str, Any]:
@@ -476,8 +486,154 @@ class CrmService:
decorated.append(item) decorated.append(item)
return decorated return decorated
def _decorate_followups(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: def _compact_customers(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [{**row, "summary": self._followup_summary(row)} for row in rows] 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]: def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]:
content_fields = list(self.config.followup.search_fields or []) + list(FOLLOWUP_CONTENT_KEYS) 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)) 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: def _int_or_default(value: Any, default: int) -> int:
try: try:
return int(value) return int(value)
@@ -593,6 +759,7 @@ def _decorate_dimension_counts(
item["label"] = label item["label"] = label
item[display_key] = label item[display_key] = label
decorated.append(item) decorated.append(item)
decorated.sort(key=lambda item: (_is_blank(item.get("value")), -_count_value(item), str(item.get("label") or "")))
return decorated return decorated
@@ -608,6 +775,25 @@ def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple
return None, None 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: def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
for field in _unique_strings(fields): for field in _unique_strings(fields):
value = row.get(field) value = row.get(field)
@@ -619,6 +805,14 @@ def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
return 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]: def _relation_labels(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]:
labels: list[str] = [] labels: list[str] = []
for field in _unique_strings(fields): 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 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: def _append_relation_labels(labels: list[str], value: Any, label_keys: tuple[str, ...]) -> None:
if value is None or value == "": if value is None or value == "":
return return
+14 -1
View File
@@ -29,7 +29,8 @@ class NbClient:
) -> None: ) -> None:
self.env_name = env_name self.env_name = env_name
self.data_source = data_source 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.api_token = api_token
self.role = role self.role = role
self.cwd = Path(cwd).resolve() if cwd else None self.cwd = Path(cwd).resolve() if cwd else None
@@ -212,6 +213,7 @@ class NbClient:
env = os.environ.copy() env = os.environ.copy()
env["NB_CLI_LOG_DISABLED"] = "1" env["NB_CLI_LOG_DISABLED"] = "1"
env["NB_SKIP_STARTUP_UPDATE"] = "1" env["NB_SKIP_STARTUP_UPDATE"] = "1"
self._prepend_nb_bin_dir_to_path(env)
return self.runner( return self.runner(
command, command,
cwd=str(self.cwd) if self.cwd else None, cwd=str(self.cwd) if self.cwd else None,
@@ -222,6 +224,17 @@ class NbClient:
check=False, 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: def _format_error(self, command: list[str], result: subprocess.CompletedProcess[str]) -> str:
return ( return (
f"nb command failed with exit code {result.returncode}: {self._redact_command(command)}\n" 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 argparse
import hmac import hmac
import os import os
import subprocess
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any, Callable
try: try:
from pydantic import Field from pydantic import Field
@@ -23,7 +24,7 @@ except ModuleNotFoundError:
from .config import load_config from .config import load_config
from .crm import CrmService from .crm import CrmService
from .nb_client import NbClient from .nb_client import NbClient, NbCommandError
DEFAULT_HTTP_HOST = "0.0.0.0" DEFAULT_HTTP_HOST = "0.0.0.0"
@@ -89,10 +90,48 @@ class BearerTokenAuthMiddleware:
def _make_service(config_path: str | Path | None = None) -> CrmService: def _make_service(config_path: str | Path | None = None) -> CrmService:
config = load_config(config_path) 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) 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( def create_server(
*, *,
crm_service: CrmService | None = None, crm_service: CrmService | None = None,
@@ -125,7 +164,7 @@ def create_server(
@server.tool() @server.tool()
def crm_health() -> dict[str, Any]: def crm_health() -> dict[str, Any]:
"""Check NocoBase CLI reachability and the configured CRM collection mapping.""" """Check NocoBase CLI reachability and the configured CRM collection mapping."""
return service.crm_health() return _safe_tool_result(service.crm_health)
@server.tool() @server.tool()
def crm_schema( def crm_schema(
@@ -135,7 +174,7 @@ def create_server(
] = False, ] = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return the configured CRM collection and field mapping for this MCP server.""" """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() @server.tool()
def crm_overview( def crm_overview(
@@ -153,7 +192,7 @@ def create_server(
] = None, ] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return CRM totals, common customer distributions, and recent follow-ups.""" """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() @server.tool()
def crm_filter_options( def crm_filter_options(
@@ -163,21 +202,21 @@ def create_server(
] = None, ] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return available customer filter values and optional owner candidates.""" """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() @server.tool()
def resolve_owner( def resolve_owner(
query: Annotated[str, Field(description="Owner username, nickname, email, or English name to resolve.")], query: Annotated[str, Field(description="Owner username, nickname, email, or English name to resolve.")],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return candidate NocoBase users for an owner filter before querying CRM data.""" """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": if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
@server.tool() @server.tool()
def crm_config_template() -> dict[str, Any]: def crm_config_template() -> dict[str, Any]:
"""Infer a config.json template from collection field metadata without reading business records.""" """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() @server.tool()
def list_customers( def list_customers(
@@ -187,9 +226,20 @@ def create_server(
source: Annotated[str | None, Field(description="Optional exact customer source.")] = None, source: Annotated[str | None, Field(description="Optional exact customer source.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20, 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, 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]: ) -> dict[str, Any]:
"""List CRM customers with pagination metadata.""" """List CRM customers with compact rows and pagination metadata."""
return service.list_customers(query=query, status=status, owner=owner, source=source, limit=limit, offset=offset) 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() @server.tool()
def get_customer( def get_customer(
@@ -197,13 +247,20 @@ def create_server(
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True, include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = 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, 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]: ) -> dict[str, Any]:
"""Get one customer and optionally include contacts and recent follow-ups.""" """Get one customer and optionally include contacts and recent follow-ups."""
return service.get_customer( return _safe_tool_result(
customer_id, lambda: service.get_customer(
include_contacts=include_contacts, customer_id,
include_recent_followups=include_recent_followups, include_contacts=include_contacts,
followup_limit=followup_limit, include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
include_raw_followups=include_raw_followups,
)
) )
@server.tool() @server.tool()
@@ -212,9 +269,18 @@ def create_server(
query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None, query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20, 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, 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]: ) -> dict[str, Any]:
"""List CRM contacts, optionally filtered by customer.""" """List CRM contacts as compact rows, optionally filtered by customer."""
return service.list_contacts(customer_id=customer_id, query=query, limit=limit, offset=offset) return _safe_tool_result(
lambda: service.list_contacts(
customer_id=customer_id,
query=query,
limit=limit,
offset=offset,
include_raw=include_raw,
)
)
@server.tool() @server.tool()
def list_followups( def list_followups(
@@ -225,16 +291,20 @@ def create_server(
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None, 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, 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, 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]: ) -> dict[str, Any]:
"""List customer follow-up records with pagination metadata.""" """List customer follow-up records as compact rows with pagination metadata."""
return service.list_followups( return _safe_tool_result(
customer_id=customer_id, lambda: service.list_followups(
contact_id=contact_id, customer_id=customer_id,
owner=owner, contact_id=contact_id,
date_from=date_from, owner=owner,
date_to=date_to, date_from=date_from,
limit=limit, date_to=date_to,
offset=offset, limit=limit,
offset=offset,
include_raw=include_raw,
)
) )
return server return server
+67 -7
View File
@@ -10,6 +10,7 @@ import shlex
import shutil import shutil
import socket import socket
import subprocess import subprocess
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
@@ -24,6 +25,7 @@ DEFAULT_PORT = 10087
DEFAULT_PATH = "/mcp" DEFAULT_PATH = "/mcp"
DEFAULT_NB_ENV = "nb-mcp" DEFAULT_NB_ENV = "nb-mcp"
DEFAULT_CONFIG_PATH = "config.json" DEFAULT_CONFIG_PATH = "config.json"
DEFAULT_NB_BIN = "nb"
DEFAULT_TOKEN_BYTES = 32 DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
@@ -51,6 +53,20 @@ def find_uv_path() -> Path:
return Path(discovered).expanduser().resolve() 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: def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths:
root = project_root.expanduser().resolve() root = project_root.expanduser().resolve()
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) 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, path: str = DEFAULT_PATH,
nb_env: str = DEFAULT_NB_ENV, nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH, config_path: str = DEFAULT_CONFIG_PATH,
nb_bin: str | Path = DEFAULT_NB_BIN,
) -> None: ) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.parent.chmod(0o700) 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_PATH={path}\n")
env_file.write(f"NOCOBASE_MCP_ENV={nb_env}\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_CONFIG={config_path}\n")
env_file.write(f"NOCOBASE_MCP_NB_BIN={nb_bin}\n")
env_path.chmod(0o600) env_path.chmod(0o600)
@@ -145,7 +163,7 @@ def read_service_env_file(env_path: Path) -> dict[str, str]:
return values 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) paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path) values = read_service_env_file(paths.env_path)
token = values.get("NOCOBASE_MCP_TOKEN", "").strip() 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 path = values.get("NOCOBASE_MCP_PATH") or DEFAULT_PATH
nb_env = values.get("NOCOBASE_MCP_ENV") or DEFAULT_NB_ENV nb_env = values.get("NOCOBASE_MCP_ENV") or DEFAULT_NB_ENV
config_path = values.get("NOCOBASE_MCP_CONFIG") or DEFAULT_CONFIG_PATH 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: 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: 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( return render_remote_config(
hosts=detect_remote_hosts(), hosts=detect_remote_hosts(),
token=token, 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} 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: def restart_service_if_installed(paths: ServicePaths) -> bool:
if not paths.unit_path.exists(): if not paths.unit_path.exists():
return False return False
@@ -288,13 +326,23 @@ def new_token(
) -> str: ) -> str:
paths = resolve_service_paths(project_root) paths = resolve_service_paths(project_root)
token = generate_token(token_bytes) 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) restart_service_if_installed(paths)
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path, service_status=service_state()) 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: def start_service(*, project_root: Path) -> str:
paths = resolve_service_paths(project_root) paths = resolve_service_paths(project_root)
nb_bin = find_nb_path()
if not paths.env_path.exists(): if not paths.env_path.exists():
write_token_env_file( write_token_env_file(
paths.env_path, paths.env_path,
@@ -304,8 +352,19 @@ def start_service(*, project_root: Path) -> str:
path=DEFAULT_PATH, path=DEFAULT_PATH,
nb_env=DEFAULT_NB_ENV, nb_env=DEFAULT_NB_ENV,
config_path=DEFAULT_CONFIG_PATH, 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) write_service_unit(paths, host=host, port=port, path=path)
run_systemctl(["daemon-reload"]) run_systemctl(["daemon-reload"])
run_systemctl(["enable", SERVICE_NAME]) run_systemctl(["enable", SERVICE_NAME])
@@ -314,7 +373,7 @@ def start_service(*, project_root: Path) -> str:
run_systemctl(["is-active", "--quiet", SERVICE_NAME]) run_systemctl(["is-active", "--quiet", SERVICE_NAME])
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise RuntimeError(f"{SERVICE_NAME} did not become active after restart") from 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"): if not probe.get("reachable"):
raise RuntimeError(f"{SERVICE_NAME} is active but HTTP probe failed: {probe.get('error')}") 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") 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}", f"Env file: {paths.env_path}",
] ]
if paths.env_path.exists(): 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() hosts = detect_remote_hosts()
host = hosts[0] if hosts else "<server-ip>" host = hosts[0] if hosts else "<server-ip>"
lines.append(f"MCP URL: http://{host}:{port}{path}") 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) probe = probe_http_service("127.0.0.1", port, path, token=token)
if probe.get("reachable"): if probe.get("reachable"):
lines.append(f"HTTP probe: reachable (status {probe.get('status')})") lines.append(f"HTTP probe: reachable (status {probe.get('status')})")
+96 -15
View File
@@ -32,17 +32,58 @@ class FakeNbClient:
], ],
"meta": {"count": 1}, "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": if resource == "followups":
return { return {
"data": [ "data": [
{ {
"id": 1, "id": 1,
"f_srysmjkwfq1": "2026-07-08T09:00:00.000Z", "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": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
"gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}], "gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
"gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}], "gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"createdBy": {"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}, "meta": {"count": 1},
@@ -58,9 +99,20 @@ class FakeNbClient:
dimensions = kwargs.get("dimensions") or [] dimensions = kwargs.get("dimensions") or []
alias = dimensions[0].get("alias") if dimensions else None alias = dimensions[0].get("alias") if dimensions else None
if alias in {"status", "kh_progress"}: 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"}: 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": if alias == "owner_id":
return {"data": [{"count": 1, "owner_id": 54}]} return {"data": [{"count": 1, "owner_id": 54}]}
if alias == "kehu_followup_person_id": 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) 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(result["total"], 1)
self.assertEqual(client.list_calls[0][0], "users") self.assertEqual(client.list_calls[0][0], "users")
resource, kwargs = client.list_calls[1] resource, kwargs = client.list_calls[1]
@@ -143,9 +200,14 @@ class CrmServiceTests(unittest.TestCase):
self.assertEqual(result["customer"]["id"], 7) self.assertEqual(result["customer"]["id"], 7)
self.assertEqual(result["customer"]["name"], "Acme") 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]["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.get_calls[0][0], "customers")
self.assertEqual(client.list_calls[0][0], "contacts") self.assertEqual(client.list_calls[0][0], "contacts")
self.assertEqual(client.list_calls[0][1]["filter"], {"$and": [{"customer": {"id": {"$eq": 7}}}]}) 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"]["customers"], 3)
self.assertEqual(result["totals"]["contacts"], 3) self.assertEqual(result["totals"]["contacts"], 3)
self.assertEqual(result["totals"]["followups"], 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.assertEqual(result["customer_owner"][0]["owner_id"], 54)
self.assertGreaterEqual(len(client.query_calls), 3) self.assertGreaterEqual(len(client.query_calls), 3)
self.assertEqual(client.query_calls[0][0], "customers") self.assertEqual(client.query_calls[0][0], "customers")
@@ -299,11 +362,19 @@ class CrmServiceTests(unittest.TestCase):
client = FakeNbClient() client = FakeNbClient()
result = CrmService(load_config(None), client).crm_filter_options(owner_query="alice") 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]["value"], "kh_p_ordered")
self.assertEqual(result["customer_status"][0]["label"], "进行中") self.assertEqual(result["customer_status"][0]["label"], "已下单")
self.assertEqual(result["customer_source"][0]["value"], "客户推荐") self.assertEqual(result["customer_source"][0]["value"], "客户推荐")
self.assertEqual(result["customer_source"][-1]["value"], "")
self.assertEqual(result["owner_candidates"][0]["id"], 54) 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: def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None:
client = FakeNbClient() client = FakeNbClient()
service = CrmService(load_config(None), client) 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_size"], 100)
self.assertEqual(client.list_calls[0][1]["page"], 1) 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) result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1)
row = result["results"][0] row = result["results"][0]
self.assertEqual(row["f_19iplxz0nds"], "Called customer about sample status") self.assertEqual(row["id"], 1)
self.assertEqual(row["summary"]["content"], "Called customer about sample status") self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.")
self.assertEqual(row["summary"]["followedAt"], "2026-07-08T09:00:00.000Z") self.assertEqual(row["followedAt"], "2026-07-08T09:00:00.000Z")
self.assertEqual(row["summary"]["customer_names"], ["Acme China"]) self.assertEqual(row["customer_names"], ["Acme China"])
self.assertEqual(row["summary"]["owner_names"], ["Alice Wang"]) 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__": if __name__ == "__main__":
+82
View File
@@ -63,6 +63,7 @@ class McpServiceConfigTests(unittest.TestCase):
path="/mcp", path="/mcp",
nb_env="nb-mcp", nb_env="nb-mcp",
config_path="config.json", config_path="config.json",
nb_bin="/opt/node/bin/nb",
) )
content = env_path.read_text(encoding="utf-8") 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_PATH=/mcp\n", content)
self.assertIn("NOCOBASE_MCP_ENV=nb-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_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).st_mode), 0o600)
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700) 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: def test_render_remote_config_prints_url_and_authorization_header(self) -> None:
output = manage_mcp_service.render_remote_config( output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10"], hosts=["192.168.1.10"],
@@ -100,6 +142,7 @@ class McpServiceConfigTests(unittest.TestCase):
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), 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, "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, "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, patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
): ):
output = manage_mcp_service.new_token(project_root=project_root) 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") env_content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", env_content) self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", env_content)
self.assertIn("NOCOBASE_MCP_PORT=10087\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) self.assertIn("http://10.0.0.2:10087/mcp", output)
run_systemctl.assert_not_called() run_systemctl.assert_not_called()
@@ -129,6 +173,7 @@ class McpServiceConfigTests(unittest.TestCase):
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), 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, "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_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, "run_systemctl") as run_systemctl,
patch.object( patch.object(
manage_mcp_service, manage_mcp_service,
@@ -141,8 +186,10 @@ class McpServiceConfigTests(unittest.TestCase):
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
unit_content = unit_path.read_text(encoding="utf-8") 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("/home/nac/.local/bin/uv run --no-sync nocobase-crm-mcp", unit_content)
self.assertIn("EnvironmentFile=", 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("http://10.0.0.2:10087/mcp", output)
self.assertIn("Bearer saved-token", output) self.assertIn("Bearer saved-token", output)
run_systemctl.assert_has_calls( 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, "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, "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_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, "run_systemctl") as run_systemctl,
patch.object( patch.object(
manage_mcp_service, manage_mcp_service,
@@ -177,6 +225,7 @@ class McpServiceConfigTests(unittest.TestCase):
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env" 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_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) self.assertIn("Bearer first-token", output)
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
self.assertTrue(unit_path.exists()) 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") 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: def test_start_service_fails_when_systemd_does_not_become_active(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project" project_root = Path(tmp) / "project"
@@ -208,6 +289,7 @@ class McpServiceConfigTests(unittest.TestCase):
with ( with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), 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_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), patch.object(manage_mcp_service, "run_systemctl", side_effect=fake_systemctl),
): ):
with self.assertRaisesRegex(RuntimeError, "did not become active"): 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("--orders", commands[1])
self.assertIn("--timezone", 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: def test_list_collection_fields_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = [] commands: list[list[str]] = []
+59
View File
@@ -2,6 +2,8 @@ import asyncio
import unittest import unittest
from unittest.mock import patch 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 from nocobase_crm_mcp.server import BearerTokenAuthMiddleware, FastMCP, create_http_app, create_server
@@ -59,6 +61,16 @@ class FakeCrmService:
return {"results": []} 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): class ServerTests(unittest.TestCase):
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None: def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret") 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: def test_config_template_tool_is_opt_in(self) -> None:
if FastMCP is None: if FastMCP is None:
self.skipTest("MCP SDK is not installed") self.skipTest("MCP SDK is not installed")