diff --git a/README.md b/README.md index 5d68b95..f4f7f7f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nocobase_crm_mcp/crm.py b/nocobase_crm_mcp/crm.py index af51b15..6203f16 100644 --- a/nocobase_crm_mcp/crm.py +++ b/nocobase_crm_mcp/crm.py @@ -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 diff --git a/nocobase_crm_mcp/nb_client.py b/nocobase_crm_mcp/nb_client.py index ec19f72..fc1ebf2 100644 --- a/nocobase_crm_mcp/nb_client.py +++ b/nocobase_crm_mcp/nb_client.py @@ -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" diff --git a/nocobase_crm_mcp/server.py b/nocobase_crm_mcp/server.py index 29e9a51..231f863 100644 --- a/nocobase_crm_mcp/server.py +++ b/nocobase_crm_mcp/server.py @@ -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 diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index 05ee77b..255bb96 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -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 "" 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')})") diff --git a/tests/test_crm_service.py b/tests/test_crm_service.py index 2bdcc7e..f924950 100644 --- a/tests/test_crm_service.py +++ b/tests/test_crm_service.py @@ -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__": diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 163e7f0..90f3fe6 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -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"): diff --git a/tests/test_nb_client.py b/tests/test_nb_client.py index 1a4122c..7498b97 100644 --- a/tests/test_nb_client.py +++ b/tests/test_nb_client.py @@ -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]] = [] diff --git a/tests/test_server.py b/tests/test_server.py index 5908aa1..cf99630 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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")