From e4be04a4c386675656b6a3abe279ea7a3170c9a9 Mon Sep 17 00:00:00 2001 From: nac Date: Sat, 15 Aug 2026 01:46:19 +0000 Subject: [PATCH] refactor: simplify CRM MCP architecture --- README.md | 4 + docs/ARCHITECTURE.md | 29 + nocobase_crm_mcp/config.py | 299 +-- nocobase_crm_mcp/crm.py | 2185 +-------------------- nocobase_crm_mcp/crm_analytics.py | 183 ++ nocobase_crm_mcp/crm_core.py | 74 + nocobase_crm_mcp/crm_directory.py | 308 +++ nocobase_crm_mcp/crm_metadata.py | 184 ++ nocobase_crm_mcp/crm_mixin.py | 16 + nocobase_crm_mcp/crm_presenters.py | 282 +++ nocobase_crm_mcp/crm_records.py | 366 ++++ nocobase_crm_mcp/crm_reporting.py | 376 ++++ nocobase_crm_mcp/crm_schema_helpers.py | 145 ++ nocobase_crm_mcp/crm_shared.py | 472 +++++ nocobase_crm_mcp/server.py | 651 +----- nocobase_crm_mcp/server_runtime.py | 150 ++ nocobase_crm_mcp/server_tools_core.py | 139 ++ nocobase_crm_mcp/server_tools_projects.py | 80 + nocobase_crm_mcp/server_tools_records.py | 161 ++ nocobase_crm_mcp/server_tools_write.py | 85 + nocobase_crm_mcp/tool_errors.py | 84 + scripts/agent_install.py | 626 +----- scripts/agent_installer/__init__.py | 15 + scripts/agent_installer/auth.py | 230 +++ scripts/agent_installer/cli.py | 86 + scripts/agent_installer/common.py | 89 + scripts/agent_installer/diagnostics.py | 86 + scripts/agent_installer/project.py | 199 ++ scripts/manage_mcp_service.py | 654 +----- scripts/mcp_service_cli.py | 89 + scripts/mcp_service_config.py | 264 +++ scripts/mcp_service_runtime.py | 354 ++++ tests/test_config_filters.py | 11 + tests/test_mcp_service.py | 103 +- 34 files changed, 4922 insertions(+), 4157 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 nocobase_crm_mcp/crm_analytics.py create mode 100644 nocobase_crm_mcp/crm_core.py create mode 100644 nocobase_crm_mcp/crm_directory.py create mode 100644 nocobase_crm_mcp/crm_metadata.py create mode 100644 nocobase_crm_mcp/crm_mixin.py create mode 100644 nocobase_crm_mcp/crm_presenters.py create mode 100644 nocobase_crm_mcp/crm_records.py create mode 100644 nocobase_crm_mcp/crm_reporting.py create mode 100644 nocobase_crm_mcp/crm_schema_helpers.py create mode 100644 nocobase_crm_mcp/crm_shared.py create mode 100644 nocobase_crm_mcp/server_runtime.py create mode 100644 nocobase_crm_mcp/server_tools_core.py create mode 100644 nocobase_crm_mcp/server_tools_projects.py create mode 100644 nocobase_crm_mcp/server_tools_records.py create mode 100644 nocobase_crm_mcp/server_tools_write.py create mode 100644 nocobase_crm_mcp/tool_errors.py create mode 100644 scripts/agent_installer/__init__.py create mode 100644 scripts/agent_installer/auth.py create mode 100644 scripts/agent_installer/cli.py create mode 100644 scripts/agent_installer/common.py create mode 100644 scripts/agent_installer/diagnostics.py create mode 100644 scripts/agent_installer/project.py create mode 100644 scripts/mcp_service_cli.py create mode 100644 scripts/mcp_service_config.py create mode 100644 scripts/mcp_service_runtime.py diff --git a/README.md b/README.md index bcd741c..fabc28d 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,10 @@ For either write-enabled mode, the logged-in user additionally needs: Keep `NOCOBASE_MCP_ENABLE_UPLOADS=0` for read-only users and project roles without create/update access. Before enabling uploads, test the exact role with both `roles:check` and a real denied/allowed upload case. Do not grant project or attachment delete permissions solely for MCP use; this server does not expose deletes, and operators should handle any retained orphan attachments through an audited administrative cleanup process. +## Internal architecture + +The internal module boundaries and compatibility rules are documented in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + ## Local HTTP MCP (optional) HTTP mode uses streamable HTTP with a separate local bearer token for MCP access: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e7efc44 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,29 @@ +# Internal architecture + +The default data path is: + +```text +MCP client -> server facade -> CRM domain mixins -> NbClient -> local nb CLI -> NocoBase +``` + +The local `nb` environment is the identity boundary. The MCP does not keep a shared NocoBase account, and NocoBase ACL remains authoritative. + +## Package boundaries + +- `crm.py` is the stable `CrmService` facade. Metadata discovery, directory resolution, record queries, presentation, analytics, and reporting live in separate `crm_*` modules. +- `server.py` owns dependency assembly and the public command entrypoint. The four `server_tools_*` modules register the existing MCP contracts; `server_runtime.py` owns HTTP authentication and ASGI assembly; `tool_errors.py` owns structured error mapping. +- `config.py` keeps the public frozen configuration dataclasses and uses one data-driven section loader for camelCase and snake_case JSON aliases. +- `nb_client.py` is the only read/query adapter to the local `nb` CLI. `nocobase_http.py` is limited to HTTP operations the CLI does not expose cleanly, such as multipart upload and attachment relations. + +## Bootstrap and advanced deployment + +- `scripts/agent_install.py` is a compatibility entrypoint. Its standard-library-only implementation lives in `scripts/agent_installer/`, so `inspect` and secure Key connection can run before project dependencies are synchronized. +- `scripts/manage_mcp_service.py` is the compatibility entrypoint for optional Linux loopback HTTP mode. Configuration serialization, runtime operations, and CLI parsing live in separate `mcp_service_*` modules. + +Default installation remains stdio, read-only, without a background service, listener port, uploads, raw rows, or shared credentials. + +## Compatibility rules + +- Keep the `nocobase-crm-mcp` console entrypoint and `CrmService` import stable. +- Treat MCP tool names, input schemas, response shapes, configuration aliases, installer commands, and service-manager commands as public contracts. +- Personal API Keys may only enter the `nb` CLI private TTY prompt. Never move them through Agent chat, argv, environment variables, logs, or temporary files. diff --git a/nocobase_crm_mcp/config.py b/nocobase_crm_mcp/config.py index 5e13f51..cad5143 100644 --- a/nocobase_crm_mcp/config.py +++ b/nocobase_crm_mcp/config.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import os -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, fields, replace from pathlib import Path from typing import Any @@ -30,9 +30,20 @@ CUSTOMER_DETAIL_FIELDS = { class CustomerConfig: resource: str = "customers" id_field: str = "id" - fields: list[str] | None = None - search_fields: list[str] | None = None - name_fields: list[str] | None = None + fields: list[str] | None = field( + default_factory=lambda: ["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"] + ) + search_fields: list[str] | None = field(default_factory=lambda: ["name", "code"]) + name_fields: list[str] | None = field( + default_factory=lambda: [ + "f_q2q60q7bqbg", + "f_kh_english_name", + "f_territorial_name", + "name", + "title", + "code", + ] + ) english_name_field: str = "f_kh_english_name" territorial_name_field: str = "f_territorial_name" status_field: str = "status" @@ -40,61 +51,43 @@ class CustomerConfig: owner_field: str = "owner" manager_field: str = "kehu_manage_person" detail_fields: dict[str, str] = field(default_factory=lambda: CUSTOMER_DETAIL_FIELDS.copy()) - sort: list[str] | None = None - - def __post_init__(self) -> None: - if self.fields is None: - object.__setattr__( - self, - "fields", - ["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"], - ) - if self.search_fields is None: - object.__setattr__(self, "search_fields", ["name", "code"]) - if self.name_fields is None: - object.__setattr__( - self, - "name_fields", - ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name", "name", "title", "code"], - ) - if self.sort is None: - object.__setattr__(self, "sort", ["-updatedAt"]) + sort: list[str] | None = field(default_factory=lambda: ["-updatedAt"]) @dataclass(frozen=True) class ContactConfig: resource: str = "contacts" id_field: str = "id" - fields: list[str] | None = None - search_fields: list[str] | None = None - name_fields: list[str] | None = None + fields: list[str] | None = field( + default_factory=lambda: ["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"] + ) + search_fields: list[str] | None = field(default_factory=lambda: ["name", "phone", "email"]) + name_fields: list[str] | None = field(default_factory=lambda: ["f_2zpg91gtmwp", "name", "title"]) title_field: str = "f_tn8zdm2f938" phone_field: str = "khlxr_phone_number" email_field: str = "khlxr_email" customer_field: str = "customer" - sort: list[str] | None = None - - def __post_init__(self) -> None: - if self.fields is None: - object.__setattr__( - self, - "fields", - ["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"], - ) - if self.search_fields is None: - object.__setattr__(self, "search_fields", ["name", "phone", "email"]) - if self.name_fields is None: - object.__setattr__(self, "name_fields", ["f_2zpg91gtmwp", "name", "title"]) - if self.sort is None: - object.__setattr__(self, "sort", ["-updatedAt"]) + sort: list[str] | None = field(default_factory=lambda: ["-updatedAt"]) @dataclass(frozen=True) class FollowupConfig: resource: str = "followups" id_field: str = "id" - fields: list[str] | None = None - search_fields: list[str] | None = None + fields: list[str] | None = field( + default_factory=lambda: [ + "id", + "customer", + "contact", + "owner", + "content", + "followedAt", + "nextFollowAt", + "createdAt", + "updatedAt", + ] + ) + search_fields: list[str] | None = field(default_factory=lambda: ["content"]) customer_field: str = "customer" contact_field: str = "contact" owner_field: str = "owner" @@ -105,52 +98,26 @@ class FollowupConfig: status_field: str = "f_hskl7gjvos9" project_field: str = "f_gjjl_projects" location_field: str = "f_b5c4hfi5og6" - sort: list[str] | None = None + sort: list[str] | None = field(default_factory=lambda: ["-followedAt", "-createdAt"]) def __post_init__(self) -> None: if self.owner_fields is None: object.__setattr__(self, "owner_fields", [self.owner_field] if self.owner_field else []) - if self.fields is None: - object.__setattr__( - self, - "fields", - [ - "id", - "customer", - "contact", - "owner", - "content", - "followedAt", - "nextFollowAt", - "createdAt", - "updatedAt", - ], - ) - if self.search_fields is None: - object.__setattr__(self, "search_fields", ["content"]) - if self.sort is None: - object.__setattr__(self, "sort", ["-followedAt", "-createdAt"]) @dataclass(frozen=True) class UserConfig: resource: str = "users" id_field: str = "id" - fields: list[str] | None = None - match_fields: list[str] | None = None - label_fields: list[str] | None = None - - def __post_init__(self) -> None: - if self.fields is None: - object.__setattr__( - self, - "fields", - ["id", "username", "nickname", "email", "f_yh_english_name"], - ) - if self.match_fields is None: - object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"]) - if self.label_fields is None: - object.__setattr__(self, "label_fields", ["nickname", "f_yh_english_name", "username", "email"]) + fields: list[str] | None = field( + default_factory=lambda: ["id", "username", "nickname", "email", "f_yh_english_name"] + ) + match_fields: list[str] | None = field( + default_factory=lambda: ["username", "nickname", "email", "f_yh_english_name"] + ) + label_fields: list[str] | None = field( + default_factory=lambda: ["nickname", "f_yh_english_name", "username", "email"] + ) @dataclass(frozen=True) @@ -199,12 +166,12 @@ class CrmConfig: env_name: str = "nb-mcp" data_source: str = "main" timezone: str = "Asia/Shanghai" - customer: CustomerConfig = CustomerConfig() - contact: ContactConfig = ContactConfig() - followup: FollowupConfig = FollowupConfig() - user: UserConfig = UserConfig() - team: TeamConfig = TeamConfig() - project: ProjectConfig = ProjectConfig() + customer: CustomerConfig = field(default_factory=CustomerConfig) + contact: ContactConfig = field(default_factory=ContactConfig) + followup: FollowupConfig = field(default_factory=FollowupConfig) + user: UserConfig = field(default_factory=UserConfig) + team: TeamConfig = field(default_factory=TeamConfig) + project: ProjectConfig = field(default_factory=ProjectConfig) def _load_json(path: Path) -> dict[str, Any]: @@ -238,144 +205,76 @@ def _pick(data: dict[str, Any], *names: str) -> Any: return None -def _customer_config(data: dict[str, Any] | None) -> CustomerConfig: +def _camel_case(name: str) -> str: + head, *tail = name.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _convert_like(default: Any, value: Any) -> Any: + if isinstance(default, list): + return _string_list(value) + if isinstance(default, dict): + return _string_map(value) + return str(value) + + +def _section_overrides(base: Any, data: dict[str, Any], *, exclude: set[str] | None = None) -> dict[str, Any]: + excluded = exclude or set() + overrides: dict[str, Any] = {} + for config_field in fields(base): + name = config_field.name + if name in excluded: + continue + value = _pick(data, _camel_case(name), name) + if not value: + continue + overrides[name] = _convert_like(getattr(base, name), value) + return overrides + + +def _simple_section[SectionConfig](config_type: type[SectionConfig], data: dict[str, Any] | None) -> SectionConfig: + base = config_type() if not data: - return CustomerConfig() - base = CustomerConfig() - return replace( - base, - resource=str(_pick(data, "resource") or base.resource), - id_field=str(_pick(data, "idField", "id_field") or base.id_field), - fields=_string_list(_pick(data, "fields")) or base.fields, - search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields, - name_fields=_string_list(_pick(data, "nameFields", "name_fields")) or base.name_fields, - english_name_field=str(_pick(data, "englishNameField", "english_name_field") or base.english_name_field), - territorial_name_field=str( - _pick(data, "territorialNameField", "territorial_name_field") or base.territorial_name_field - ), - status_field=str(_pick(data, "statusField", "status_field") or base.status_field), - source_field=str(_pick(data, "sourceField", "source_field") or base.source_field), - owner_field=str(_pick(data, "ownerField", "owner_field") or base.owner_field), - manager_field=str(_pick(data, "managerField", "manager_field") or base.manager_field), - detail_fields=_string_map(_pick(data, "detailFields", "detail_fields")) or base.detail_fields, - sort=_string_list(_pick(data, "sort")) or base.sort, - ) + return base + return replace(base, **_section_overrides(base, data)) # type: ignore[type-var] + + +def _customer_config(data: dict[str, Any] | None) -> CustomerConfig: + return _simple_section(CustomerConfig, data) def _contact_config(data: dict[str, Any] | None) -> ContactConfig: - if not data: - return ContactConfig() - base = ContactConfig() - return replace( - base, - resource=str(_pick(data, "resource") or base.resource), - id_field=str(_pick(data, "idField", "id_field") or base.id_field), - fields=_string_list(_pick(data, "fields")) or base.fields, - search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields, - name_fields=_string_list(_pick(data, "nameFields", "name_fields")) or base.name_fields, - title_field=str(_pick(data, "titleField", "title_field") or base.title_field), - phone_field=str(_pick(data, "phoneField", "phone_field") or base.phone_field), - email_field=str(_pick(data, "emailField", "email_field") or base.email_field), - customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field), - sort=_string_list(_pick(data, "sort")) or base.sort, - ) + return _simple_section(ContactConfig, data) def _followup_config(data: dict[str, Any] | None) -> FollowupConfig: if not data: return FollowupConfig() base = FollowupConfig() - owner_field = str(_pick(data, "ownerField", "owner_field") or base.owner_field) - owner_fields = _string_list(_pick(data, "ownerFields", "owner_fields")) or ([owner_field] if owner_field else []) - return replace( - base, - resource=str(_pick(data, "resource") or base.resource), - id_field=str(_pick(data, "idField", "id_field") or base.id_field), - fields=_string_list(_pick(data, "fields")) or base.fields, - search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields, - customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field), - contact_field=str(_pick(data, "contactField", "contact_field") or base.contact_field), - owner_field=owner_field, - owner_fields=owner_fields, - date_field=str(_pick(data, "dateField", "date_field") or base.date_field), - content_field=str(_pick(data, "contentField", "content_field") or base.content_field), - type_field=str(_pick(data, "typeField", "type_field") or base.type_field), - status_field=str(_pick(data, "statusField", "status_field") or base.status_field), - project_field=str(_pick(data, "projectField", "project_field") or base.project_field), - location_field=str(_pick(data, "locationField", "location_field") or base.location_field), - sort=_string_list(_pick(data, "sort")) or base.sort, + overrides = _section_overrides(base, data, exclude={"owner_fields"}) + owner_field = str(overrides.get("owner_field", base.owner_field)) + overrides["owner_fields"] = _string_list(_pick(data, "ownerFields", "owner_fields")) or ( + [owner_field] if owner_field else [] ) + return replace(base, **overrides) def _user_config(data: dict[str, Any] | None) -> UserConfig: - if not data: - return UserConfig() - base = UserConfig() - return replace( - base, - resource=str(_pick(data, "resource") or base.resource), - id_field=str(_pick(data, "idField", "id_field") or base.id_field), - fields=_string_list(_pick(data, "fields")) or base.fields, - match_fields=_string_list(_pick(data, "matchFields", "match_fields")) or base.match_fields, - label_fields=_string_list(_pick(data, "labelFields", "label_fields")) or base.label_fields, - ) + return _simple_section(UserConfig, data) def _team_config(data: dict[str, Any] | None) -> TeamConfig: - if not data: - return TeamConfig() - base = TeamConfig() - return replace( - base, - resource=str(_pick(data, "resource") or base.resource), - id_field=str(_pick(data, "idField", "id_field") or base.id_field), - name_field=str(_pick(data, "nameField", "name_field") or base.name_field), - manager_field=str(_pick(data, "managerField", "manager_field") or base.manager_field), - member_field=str(_pick(data, "memberField", "member_field") or base.member_field), - user_status_field=str(_pick(data, "userStatusField", "user_status_field") or base.user_status_field), - formal_user_status_value=str( - _pick(data, "formalUserStatusValue", "formal_user_status_value") or base.formal_user_status_value - ), - ) + return _simple_section(TeamConfig, data) def _project_config(data: dict[str, Any] | None) -> ProjectConfig: if not data: return ProjectConfig() base = ProjectConfig() - return replace( - base, - resource=str(_pick(data, "resource") or base.resource), - id_field=str(_pick(data, "idField", "id_field") or base.id_field), - name_field=str(_pick(data, "nameField", "name_field") or base.name_field), - # Left as None when unset so __post_init__ re-derives it from the configured name_field. - name_fields=_string_list(_pick(data, "nameFields", "name_fields")), - customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field), - customer_relation_field=str( - _pick(data, "customerRelationField", "customer_relation_field") or base.customer_relation_field - ), - inquiry_date_field=str(_pick(data, "inquiryDateField", "inquiry_date_field") or base.inquiry_date_field), - progress_field=str(_pick(data, "progressField", "progress_field") or base.progress_field), - applications_field=str(_pick(data, "applicationsField", "applications_field") or base.applications_field), - part_numbers_field=str(_pick(data, "partNumbersField", "part_numbers_field") or base.part_numbers_field), - period_usage_field=str(_pick(data, "periodUsageField", "period_usage_field") or base.period_usage_field), - details_field=str(_pick(data, "detailsField", "details_field") or base.details_field), - attachment_field=str(_pick(data, "attachmentField", "attachment_field") or base.attachment_field), - contact_field=str(_pick(data, "contactField", "contact_field") or base.contact_field), - followup_field=str(_pick(data, "followupField", "followup_field") or base.followup_field), - update_field=str(_pick(data, "updateField", "update_field") or base.update_field), - update_resource=str(_pick(data, "updateResource", "update_resource") or base.update_resource), - update_project_field=str( - _pick(data, "updateProjectField", "update_project_field") or base.update_project_field - ), - update_date_field=str(_pick(data, "updateDateField", "update_date_field") or base.update_date_field), - update_content_field=str( - _pick(data, "updateContentField", "update_content_field") or base.update_content_field - ), - update_attachment_field=str( - _pick(data, "updateAttachmentField", "update_attachment_field") or base.update_attachment_field - ), - ) + overrides = _section_overrides(base, data, exclude={"name_fields", "progress_values"}) + # Keep this as None when unset so __post_init__ derives it from a custom name_field. + overrides["name_fields"] = _string_list(_pick(data, "nameFields", "name_fields")) + return replace(base, **overrides) def load_config(path: str | Path | None = None) -> CrmConfig: diff --git a/nocobase_crm_mcp/crm.py b/nocobase_crm_mcp/crm.py index d069e94..333f43b 100644 --- a/nocobase_crm_mcp/crm.py +++ b/nocobase_crm_mcp/crm.py @@ -1,2164 +1,25 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import UTC, date, datetime -from typing import Any -from zoneinfo import ZoneInfo - -from .config import CrmConfig -from .filters import ( - build_and_filter, - build_date_range_condition, - build_eq_condition, - build_relation_id_condition, - build_relation_id_in_condition, - build_search_filter, -) -from .nb_client import NbClient, NbCommandError - -DEFAULT_PAGE_SIZE = 20 -MAX_PAGE_SIZE = 100 -MAX_SCAN_RECORDS = 5000 -UNKNOWN_NAME_PREFIX = "#" -PROJECTION_FALLBACK_WARNING = "NocoBase rejected relation field projection; check nameFields in the CRM config" - -CUSTOMER_STATUS_LABELS = { - "kh_p_not_start": "未开始", - "kh_p_inprogress": "进行中", - "kh_p_ordered": "已下单", - "kh_p_audited": "已审核", -} - -FOLLOWUP_CONTENT_KEYS = ("f_19iplxz0nds", "content", "description", "remark") -FOLLOWUP_DATE_KEYS = ("f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt") -FOLLOWUP_TYPE_LABELS = { - "tdwfk5h1ohv": "邮件", - "s48pnyhutwc": "电话", - "mwyy8xwk66m": "视频会议", - "m34aocz3ebo": "拜访/请客", - "0lmj78zpdnm": "审厂/来访", - "o7rjlg4guzh": "论坛/大会", - "2csix8r3h0f": "代理跟进情况代填", -} -FOLLOWUP_STATUS_LABELS = { - "2rowk1bc8vc": "审批中", - "3ge6geal2u1": "OK", - "68vqz8raus6": "迟交", - "b5pf5njqgp6": "无效", -} -INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6" - - -@dataclass(frozen=True) -class LabelSpec: - """How to identify and name one related record. - - Every relation the API renders (customer, contact, user, project) resolves to - the same ``{"id": ..., "name": ...}`` shape through this spec, so the same - entity looks identical in every tool response. - """ - - id_field: str = "id" - name_fields: tuple[str, ...] = () - - def projection_fields(self) -> list[str]: - return _unique_strings([self.id_field, *self.name_fields]) - - -class CrmService: - def __init__(self, config: CrmConfig, client: NbClient) -> None: - self.config = config - self.client = client - - def _customer_spec(self) -> LabelSpec: - return LabelSpec(self.config.customer.id_field, tuple(self.config.customer.name_fields or ())) - - def _contact_spec(self) -> LabelSpec: - return LabelSpec(self.config.contact.id_field, tuple(self.config.contact.name_fields or ())) - - def _user_spec(self) -> LabelSpec: - return LabelSpec(self.config.user.id_field, tuple(self.config.user.label_fields or ())) - - def _project_spec(self) -> LabelSpec: - return LabelSpec(self.config.project.id_field, tuple(self.config.project.name_fields or ())) - - def _team_fields(self) -> list[str]: - # Only the related user ids are needed: _team_users re-queries the users - # collection for their details, and _compact_team just counts members. - team = self.config.team - id_only = LabelSpec(self.config.user.id_field, ()) - return _fields_with_projections( - [team.id_field, team.name_field], - [ - *self._projection(team.manager_field, id_only, None), - *self._projection(team.member_field, id_only, None), - ], - ) - - def _projection(self, relation: str | None, spec: LabelSpec, configured_fields: list[str] | None) -> list[str]: - """Build dotted field paths that fetch only a relation's id and name. - - Without this NocoBase expands every related row in full -- junction table - rows, audit user objects, long free text -- none of which is rendered. - The configured-fields whitelist still applies, just to the relation root - rather than to each dotted path. - """ - if not relation: - return [] - if configured_fields and relation not in configured_fields: - return [] - return [f"{relation}.{name}" for name in spec.projection_fields()] - - def _list_with_projection_fallback(self, resource: str, *, fields: list[str], **kwargs: Any) -> dict[str, Any]: - """Run a projected list query, degrading to relation roots if it is rejected. - - A misconfigured ``nameFields`` entry would otherwise turn every read into - a hard failure; falling back keeps the tool usable and says why. - """ - try: - return self._list_records_window(resource, fields=fields, **kwargs) - except NbCommandError: - flat = _unique_strings([path.split(".", 1)[0] for path in fields]) - if flat == _unique_strings(fields): - raise - result = self._list_records_window(resource, fields=flat, **kwargs) - result.setdefault("data_quality_warnings", []).append(PROJECTION_FALLBACK_WARNING) - return result - - def crm_health(self) -> dict[str, Any]: - health = self.client.health(self.config.customer.resource, self.config.customer.id_field) - configured = { - "env": self.config.env_name, - "data_source": self.config.data_source, - "collections": { - "customers": self.config.customer.resource, - "contacts": self.config.contact.resource, - "followups": self.config.followup.resource, - "teams": self.config.team.resource, - "projects": self.config.project.resource, - "project_updates": self.config.project.update_resource, - }, - } - return {"ok": bool(health.get("ok")), "nb": health, "configured": configured} - - def crm_schema(self, include_fields: bool = False) -> dict[str, Any]: - schema: dict[str, Any] = { - "env": self.config.env_name, - "data_source": self.config.data_source, - "collections": { - "customers": self.config.customer.__dict__, - "contacts": self.config.contact.__dict__, - "followups": self.config.followup.__dict__, - "users": self.config.user.__dict__, - "teams": self.config.team.__dict__, - "projects": self.config.project.__dict__, - }, - } - if include_fields: - schema["metadata"] = self._safe_metadata() - return schema - - def crm_config_template(self) -> dict[str, Any]: - metadata = self._safe_metadata() - fields_by_kind = metadata.get("fields", {}) if isinstance(metadata, dict) else {} - customer_fields = _field_list(fields_by_kind.get("customers")) - contact_fields = _field_list(fields_by_kind.get("contacts")) - followup_fields = _field_list(fields_by_kind.get("followups")) - customer_resource = self.config.customer.resource - contact_resource = self.config.contact.resource - return { - "env": self.config.env_name, - "dataSource": self.config.data_source, - "users": { - "resource": self.config.user.resource, - "fields": self.config.user.fields, - "matchFields": self.config.user.match_fields, - "labelFields": self.config.user.label_fields, - }, - "collections": { - "customers": { - "resource": customer_resource, - "fields": _field_names(customer_fields), - "searchFields": _configured_or_search_fields( - customer_fields, - ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"], - ), - # Ordered: the first non-empty one becomes the customer's - # displayed name everywhere it is referenced. - "nameFields": _existing_field_names( - customer_fields, - ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"], - ) - or self.config.customer.name_fields, - "managerField": _find_named_field(customer_fields, ["kehu_manage_person"]) - or self.config.customer.manager_field, - "detailFields": { - key: value - for key, value in self.config.customer.detail_fields.items() - if _find_named_field(customer_fields, [value]) - }, - "statusField": _find_named_field(customer_fields, ["kh_progress"]) - or _find_field(customer_fields, ["进度", "状态", "status"]), - "sourceField": _find_named_field(customer_fields, ["f_225h6n6v654"]) - or _find_field(customer_fields, ["客户来源", "来源", "source"]), - "ownerField": _find_named_field(customer_fields, ["kehu_followup_person"]) - or self.config.customer.owner_field, - "sort": self.config.customer.sort, - }, - "contacts": { - "resource": contact_resource, - "fields": _field_names(contact_fields), - "searchFields": _configured_or_search_fields( - contact_fields, - ["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"], - ), - "nameFields": _existing_field_names(contact_fields, ["f_2zpg91gtmwp"]) - or self.config.contact.name_fields, - "titleField": _find_named_field(contact_fields, ["f_tn8zdm2f938"]) - or self.config.contact.title_field, - "phoneField": _find_named_field(contact_fields, ["khlxr_phone_number"]) - or self.config.contact.phone_field, - "emailField": _find_named_field(contact_fields, ["khlxr_email"]) or self.config.contact.email_field, - "customerField": _find_named_field(contact_fields, ["kehulianxiren_client"]) - or _relation_field(contact_fields, ["公司", "客户"]) - or self.config.contact.customer_field, - "sort": self.config.contact.sort, - }, - "followups": { - "resource": self.config.followup.resource, - "fields": _field_names(followup_fields), - "searchFields": _configured_or_search_fields(followup_fields, ["f_19iplxz0nds"]), - "customerField": _find_named_field(followup_fields, ["gjjl_client"]) - or _relation_field(followup_fields, ["客户"]) - or self.config.followup.customer_field, - "contactField": _find_named_field(followup_fields, ["gjjl_client_contacts"]) - or _relation_field(followup_fields, ["客户联系人", "联系人"]) - or self.config.followup.contact_field, - "ownerField": _find_named_field(followup_fields, ["gjjl_followup_person"]) - or self.config.followup.owner_field, - "ownerFields": _existing_field_names( - followup_fields, - ["gjjl_followup_person", "createdBy"], - ) - or self.config.followup.owner_fields, - "dateField": _find_named_field(followup_fields, ["f_srysmjkwfq1"]) - or _date_field(followup_fields) - or self.config.followup.date_field, - "contentField": _find_named_field(followup_fields, ["f_19iplxz0nds"]) - or self.config.followup.content_field, - "typeField": _find_named_field(followup_fields, ["gjjl_followup_type"]) - or self.config.followup.type_field, - "statusField": _find_named_field(followup_fields, ["f_hskl7gjvos9"]) - or self.config.followup.status_field, - "projectField": _find_named_field(followup_fields, ["f_gjjl_projects"]) - or self.config.followup.project_field, - "locationField": _find_named_field(followup_fields, ["f_b5c4hfi5og6"]) - or self.config.followup.location_field, - "sort": _template_sort( - _find_named_field(followup_fields, ["f_srysmjkwfq1"]) - or _date_field(followup_fields) - or self.config.followup.date_field, - self.config.followup.sort, - ), - }, - }, - "metadata": { - "field_errors": metadata.get("field_errors", {}) if isinstance(metadata, dict) else {}, - "note": "Review inferred owner/status/source/date fields before using this template in production.", - }, - } - - def crm_overview( - self, - date_from: str | None = None, - date_to: str | None = None, - owner: str | None = None, - ) -> dict[str, Any]: - owner_id, owner_error = self._resolve_owner_id_or_error(owner) - if owner_error: - return {"error": owner_error} - customer_filter = build_and_filter([build_relation_id_condition(self.config.customer.owner_field, owner_id)]) - followup_filter = build_and_filter( - [ - build_relation_id_condition(self.config.followup.owner_field, owner_id), - build_date_range_condition(self.config.followup.date_field, date_from, date_to), - ] - ) - totals = { - "customers": self._count(self.config.customer.resource, self.config.customer.id_field, customer_filter), - "contacts": self._count(self.config.contact.resource, self.config.contact.id_field, {}), - "followups": self._count(self.config.followup.resource, self.config.followup.id_field, followup_filter), - } - overview: dict[str, Any] = { - "totals": totals, - "customer_status": self._group_count( - self.config.customer.resource, - self.config.customer.id_field, - self.config.customer.status_field, - customer_filter, - ), - "customer_source": self._group_count( - self.config.customer.resource, - self.config.customer.id_field, - self.config.customer.source_field, - customer_filter, - ), - "customer_owner": self._group_count( - self.config.customer.resource, - self.config.customer.id_field, - [self.config.customer.owner_field, "id"], - customer_filter, - ), - "recent_followups": self._compact_followups( - self._data( - self.client.list_records( - self.config.followup.resource, - filter=followup_filter, - fields=self._followup_compact_fields(), - sort=self.config.followup.sort, - page=1, - page_size=10, - ) - ) - ), - } - overview["customer_status"] = self._decorate_status_counts(overview["customer_status"]) - overview["customer_source"] = self._decorate_source_counts(overview["customer_source"]) - overview["customer_owner"] = self._decorate_owner_counts(overview["customer_owner"]) - return overview - - def crm_filter_options(self, owner_query: str | None = None) -> dict[str, Any]: - owner_candidates: list[dict[str, Any]] = [] - if (owner_query or "").strip(): - owner_candidates = self.resolve_owner(owner_query or "")["results"] - return { - "customer_status": self._decorate_status_counts( - self._group_count( - self.config.customer.resource, - self.config.customer.id_field, - self.config.customer.status_field, - {}, - ) - ), - "customer_source": self._decorate_source_counts( - self._group_count( - self.config.customer.resource, - self.config.customer.id_field, - self.config.customer.source_field, - {}, - ) - ), - # Follow-up rows render type and status as labels only, so the raw - # codes the filters expect are published here instead. - "followup_type": [{"value": value, "label": label} for value, label in FOLLOWUP_TYPE_LABELS.items()], - "followup_record_status": [ - {"value": value, "label": label} for value, label in FOLLOWUP_STATUS_LABELS.items() - ], - "owner_candidates": owner_candidates, - "max_limit": MAX_PAGE_SIZE, - } - - def team_crm_overview( - self, - team: str, - *, - date_from: str | None = None, - date_to: str | None = None, - include_member_breakdown: bool = True, - ) -> dict[str, Any]: - matches = self._team_matches((team or "").strip()) - member_ids, error = self._resolve_team_member_ids_or_error(team) - if error: - return {"error": error} - assert member_ids is not None - team_row = matches[0] - members = self._team_users(team_row, self.config.team.member_field, formal_only=True) - customer_filter = build_and_filter( - [build_relation_id_in_condition(self.config.customer.owner_field, member_ids)] - ) - followup_filter = build_and_filter( - [ - build_relation_id_in_condition(self.config.followup.owner_field, member_ids), - build_date_range_condition(self.config.followup.date_field, date_from, date_to), - {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, - ] - ) - project_filter = build_and_filter( - [ - build_relation_id_in_condition( - [self.config.project.customer_relation_field, self.config.customer.owner_field, "id"], - member_ids, - ), - { - self.config.project.progress_field: { - "$ne": self.config.project.progress_values["终止"], - } - }, - ] - ) - result: dict[str, Any] = { - "team": self._compact_team(team_row, include_members=True, formal_members_only=True), - "period": {"date_from": date_from, "date_to": date_to}, - "timezone": self.config.timezone, - "totals": { - "owned_customers": self._count( - self.config.customer.resource, - self.config.customer.id_field, - customer_filter, - ), - "followup_records": self._count( - self.config.followup.resource, - self.config.followup.id_field, - followup_filter, - ), - "active_projects": self._count( - self.config.project.resource, - self.config.project.id_field, - project_filter, - ), - }, - "by_followup_type": self._group_count( - self.config.followup.resource, - self.config.followup.id_field, - self.config.followup.type_field, - followup_filter, - ), - "by_customer": self._group_count( - self.config.followup.resource, - self.config.followup.id_field, - [self.config.followup.customer_field, "id"], - followup_filter, - ), - "by_project_stage": self._group_count( - self.config.project.resource, - self.config.project.id_field, - self.config.project.progress_field, - project_filter, - ), - "contribution_basis": { - "participant_field": self.config.followup.owner_field, - "team_totals": "unique follow-up record IDs", - "member_contribution": "one participation per member per follow-up", - }, - } - if include_member_breakdown: - breakdown: list[dict[str, Any]] = [] - for member in members: - member_id = member.get(self.config.user.id_field) - if member_id is None: - continue - member_customer_filter = build_and_filter( - [build_relation_id_condition(self.config.customer.owner_field, member_id)] - ) - member_followup_filter = build_and_filter( - [ - build_relation_id_condition(self.config.followup.owner_field, member_id), - build_date_range_condition(self.config.followup.date_field, date_from, date_to), - {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, - ] - ) - candidate = _format_user_candidate(member, self.config.user.id_field) - breakdown.append( - { - "member": candidate, - "owned_customers": self._count( - self.config.customer.resource, - self.config.customer.id_field, - member_customer_filter, - ), - "followup_participations": self._count( - self.config.followup.resource, - self.config.followup.id_field, - member_followup_filter, - ), - } - ) - result["by_member"] = sorted( - breakdown, - key=lambda item: (-item["followup_participations"], str(item["member"].get("label") or "")), - ) - result["by_followup_type"] = _decorate_dimension_counts( - result["by_followup_type"], - preferred_keys=[self.config.followup.type_field], - labels=FOLLOWUP_TYPE_LABELS, - ) - result["by_project_stage"] = _decorate_dimension_counts( - result["by_project_stage"], - preferred_keys=[self.config.project.progress_field], - labels={value: label for label, value in self.config.project.progress_values.items()}, - ) - result["by_customer"] = self._attach_customer_names( - _decorate_id_counts( - result["by_customer"], - preferred_keys=[ - f"{self.config.followup.customer_field}_id", - "gjjl_client_id", - ], - output_key="customer_id", - ) - ) - return result - - def _attach_customer_names( - self, - rows: list[dict[str, Any]], - *, - id_key: str = "customer_id", - max_lookups: int = MAX_PAGE_SIZE, - ) -> list[dict[str, Any]]: - """Fill in customer names for id-keyed aggregation buckets. - - Grouping runs on the aggregation endpoint and only ever yields ids, which - left callers reading rows identified by a bare number. Counts are never - touched -- this only adds a name. - """ - identifiers = _unique_values([row.get(id_key) for row in rows if row.get(id_key) is not None]) - if not identifiers: - return rows - names: dict[Any, str] = {} - try: - lookup = self.client.list_records( - self.config.customer.resource, - filter={self.config.customer.id_field: {"$in": identifiers[:max_lookups]}}, - fields=_unique_strings([self.config.customer.id_field, *(self.config.customer.name_fields or [])]), - page=1, - page_size=max_lookups, - ) - except NbCommandError: - return rows - for customer in _data(lookup): - ref = _ref_from_mapping(customer, self._customer_spec()) - if ref.get("id") is not None and ref.get("name"): - names[ref["id"]] = str(ref["name"]) - for row in rows: - name = names.get(row.get(id_key)) - if name: - row["customer_name"] = name - return rows - - def resolve_owner(self, query: str) -> dict[str, Any]: - normalized = (query or "").strip() - if not normalized: - return {"query": "", "results": [], "count": 0} - rows = self._owner_matches(normalized) - results = [_format_user_candidate(row, self.config.user.id_field) for row in rows] - return {"query": normalized, "results": results, "count": len(results)} - - def list_teams( - self, - query: str | None = None, - *, - include_members: bool = False, - formal_members_only: bool = True, - limit: int = 20, - offset: int = 0, - ) -> dict[str, Any]: - limit, offset = _normalize_pagination(limit, offset) - result = self._list_records_window( - self.config.team.resource, - limit=limit, - offset=offset, - filter=build_and_filter([build_search_filter(query, [self.config.team.name_field])]), - fields=self._team_fields(), - sort=[self.config.team.name_field], - ) - result["results"] = [ - self._compact_team( - row, - include_members=include_members, - formal_members_only=formal_members_only, - ) - for row in result["results"] - ] - return result - - def resolve_team(self, query: str) -> dict[str, Any]: - normalized = (query or "").strip() - if not normalized: - return {"query": "", "results": [], "count": 0} - rows = self._team_matches(normalized) - results = [self._compact_team(row, include_members=False, formal_members_only=True) for row in rows] - return {"query": normalized, "results": results, "count": len(results)} - - def get_team( - self, - team_id: Any, - *, - include_members: bool = True, - formal_members_only: bool = True, - ) -> dict[str, Any]: - row = self._single( - self.client.get_record( - self.config.team.resource, - team_id, - fields=self._team_fields(), - ) - ) - if not isinstance(row, Mapping): - return {"error": {"code": "team_not_found", "message": f"No team matched id {team_id!r}"}} - return { - "team": self._compact_team( - row, - include_members=include_members, - formal_members_only=formal_members_only, - ) - } - - def list_customers( - self, - query: str | None = None, - status: str | None = None, - owner: str | None = None, - team: str | None = None, - 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) - if owner_error: - return {"error": owner_error} - team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) - if team_error: - return {"error": team_error} - filter_value = build_and_filter( - [ - build_eq_condition(self.config.customer.status_field, status), - build_relation_id_condition(self.config.customer.owner_field, owner_id), - build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids), - build_eq_condition(self.config.customer.source_field, source), - build_search_filter(query, self.config.customer.search_fields), - ] - ) - result = self._list_with_projection_fallback( - self.config.customer.resource, - limit=limit, - offset=offset, - filter=filter_value, - fields=(self.config.customer.fields or []) if include_raw else self._customer_compact_fields(), - sort=self.config.customer.sort, - ) - result["results"] = self._compact_customers(result["results"], include_raw=include_raw) - result["timezone"] = self.config.timezone - return result - - def get_customer( - self, - customer_id: Any, - include_contacts: bool = True, - include_recent_followups: bool = True, - followup_limit: int = 10, - include_raw: bool = False, - ) -> dict[str, Any]: - fields = self.config.customer.fields if include_raw else self._customer_compact_fields(detail=True) - row = self._single( - self.client.get_record( - self.config.customer.resource, - customer_id, - fields=fields, - ) - ) - if not isinstance(row, Mapping): - return { - "error": { - "code": "customer_not_found", - "message": f"No customer matches id {customer_id!r}.", - "hint": "Use list_customers to find the customer id.", - "retryable": False, - } - } - result: dict[str, Any] = { - "customer": self._compact_customer(row, detail=True, include_raw=include_raw), - } - if include_contacts: - # Phone and email are deliberately withheld here: a customer overview - # does not need personal contact details. list_contacts still serves them. - contacts_result = self.list_contacts( - customer_id=customer_id, - limit=50, - offset=0, - include_details=False, - ) - result["contacts"] = contacts_result["results"] - result["contacts_pagination"] = _pagination_metadata(contacts_result) - if include_recent_followups: - followups_result = self.list_followups( - customer_id=customer_id, - limit=followup_limit, - offset=0, - include_raw=include_raw, - ) - result["recent_followups"] = followups_result["results"] - result["recent_followups_pagination"] = _pagination_metadata(followups_result) - result["timezone"] = self.config.timezone - return result - - def list_projects( - self, - *, - customer_id: Any | None = None, - team: str | None = None, - stage: str | None = None, - limit: int = 20, - offset: int = 0, - include_raw: bool = False, - ) -> dict[str, Any]: - limit, offset = _normalize_pagination(limit, offset) - team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) - if team_error: - return {"error": team_error} - stage_value = stage - if stage: - if stage in self.config.project.progress_values: - stage_value = self.config.project.progress_values[stage] - elif stage not in self.config.project.progress_values.values(): - return { - "error": { - "code": "invalid_project_stage", - "message": "stage must be one of the configured project progress labels or values", - } - } - filter_value = build_and_filter( - [ - build_eq_condition(self.config.project.customer_field, customer_id), - build_relation_id_in_condition( - [self.config.project.customer_relation_field, self.config.customer.owner_field, "id"], - team_member_ids, - ), - build_eq_condition(self.config.project.progress_field, stage_value), - ] - ) - result = self._list_with_projection_fallback( - self.config.project.resource, - limit=limit, - offset=offset, - filter=filter_value, - fields=self._project_fields(), - sort=["-updatedAt"], - ) - result["results"] = [self._compact_project(row, include_raw=include_raw) for row in result["results"]] - result["timezone"] = self.config.timezone - return result - - def get_project( - self, - project_id: Any, - *, - include_contacts: bool = True, - include_followups: bool = True, - include_updates: bool = True, - ) -> dict[str, Any]: - row = self._single( - self.client.get_record( - self.config.project.resource, - project_id, - fields=self._project_fields(), - ) - ) - if not isinstance(row, Mapping): - return {"error": {"code": "project_not_found", "message": f"No project matched id {project_id!r}"}} - project = self._compact_project(row, include_raw=False) - result: dict[str, Any] = {"project": project, "timezone": self.config.timezone} - if include_contacts: - # Was returning raw contact rows, phone and email included. - result["contacts"] = _relation_refs(row, [self.config.project.contact_field], self._contact_spec()) - if include_followups: - followups_result = self.list_followups(project_id=project_id, limit=100, offset=0) - result["followups"] = followups_result["results"] - result["followups_pagination"] = _pagination_metadata(followups_result) - if include_updates: - updates, truncation = self._scan_records( - self.config.project.update_resource, - filter=build_and_filter( - [build_relation_id_condition(self.config.project.update_project_field, project_id)] - ), - fields=[ - "id", - self.config.project.update_project_field, - self.config.project.update_date_field, - self.config.project.update_content_field, - self.config.project.update_attachment_field, - "createdBy", - "createdAt", - ], - sort=[self.config.project.update_date_field, "createdAt"], - ) - result["updates"] = [self._compact_project_update(update) for update in updates] - if truncation: - result["data_quality_warnings"] = ["project updates were truncated at the scan limit"] - result["truncation"] = {"updates": truncation} - return result - - def get_project_timeline( - self, - project_id: Any, - *, - date_from: str | None = None, - date_to: str | None = None, - ) -> dict[str, Any]: - project_result = self.get_project( - project_id, - include_contacts=False, - include_followups=False, - include_updates=False, - ) - if "error" in project_result: - return project_result - project = project_result["project"] - followup_rows, followups_truncation = self._scan_records( - self.config.followup.resource, - filter=build_and_filter( - [ - build_relation_id_condition(self.config.followup.project_field, project_id), - build_date_range_condition(self.config.followup.date_field, date_from, date_to), - ] - ), - fields=self._followup_compact_fields(), - sort=self.config.followup.sort, - ) - update_rows, updates_truncation = self._scan_records( - self.config.project.update_resource, - filter=build_and_filter( - [ - build_relation_id_condition(self.config.project.update_project_field, project_id), - build_date_range_condition(self.config.project.update_date_field, date_from, date_to), - ] - ), - fields=[ - "id", - self.config.project.update_date_field, - self.config.project.update_content_field, - "createdBy", - "createdAt", - ], - sort=[self.config.project.update_date_field, "createdAt"], - ) - events: list[dict[str, Any]] = [] - for row in followup_rows: - compact = self._compact_followup(row) - events.append( - { - "event_type": "followup", - "occurred_at": compact.get("followedAt"), - "actors": _ref_names(compact.get("participants", [])), - "summary": compact.get("content"), - "source_resource": self.config.followup.resource, - "source_id": compact.get("id"), - } - ) - for row in update_rows: - compact = self._compact_project_update(row) - events.append( - { - "event_type": "project_update", - "occurred_at": compact.get("updated_at"), - "actors": _ref_names([compact["creator"]] if compact.get("creator") else []), - "summary": compact.get("content"), - "source_resource": self.config.project.update_resource, - "source_id": compact.get("id"), - } - ) - events.sort(key=lambda event: str(event.get("occurred_at") or "")) - result = { - "project": project, - "events": events, - "timezone": self.config.timezone, - } - warnings: list[str] = [] - truncation: dict[str, Any] = {} - if followups_truncation: - warnings.append("follow-up events were truncated at the scan limit") - truncation["followups"] = followups_truncation - if updates_truncation: - warnings.append("project update events were truncated at the scan limit") - truncation["updates"] = updates_truncation - if warnings: - result["data_quality_warnings"] = warnings - result["truncation"] = truncation - return result - - def list_stale_customers( - self, - *, - team: str | None = None, - owner: str | None = None, - inactive_days: int = 30, - as_of: str | None = None, - limit: int = 20, - offset: int = 0, - ) -> dict[str, Any]: - limit, offset = _normalize_pagination(limit, offset) - inactive_days = max(1, _int_or_default(inactive_days, 30)) - owner_id, owner_error = self._resolve_owner_id_or_error(owner) - if owner_error: - return {"error": owner_error} - team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) - if team_error: - return {"error": team_error} - try: - as_of_date = date.fromisoformat(as_of) if as_of else datetime.now(ZoneInfo(self.config.timezone)).date() - except ValueError: - return {"error": {"code": "invalid_date", "message": "as_of must use YYYY-MM-DD"}} - - customer_filter = build_and_filter( - [ - build_relation_id_condition(self.config.customer.owner_field, owner_id), - build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids), - ] - ) - followup_filter = build_and_filter( - [ - build_relation_id_condition(self.config.followup.owner_field, owner_id), - build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids), - {self.config.followup.date_field: {"$dateNotAfter": as_of_date.isoformat()}}, - {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, - ] - ) - customer_rows, customers_truncation = self._scan_records( - self.config.customer.resource, - filter=customer_filter, - fields=self._customer_compact_fields(), - sort=self.config.customer.sort, - ) - followup_rows, followups_truncation = self._scan_records( - self.config.followup.resource, - filter=followup_filter, - # This scan can pull thousands of rows and only needs the customer id, - # so the customer relation is projected down to exactly that. - fields=[ - self.config.followup.id_field, - f"{self.config.followup.customer_field}.{self.config.customer.id_field}", - self.config.followup.date_field, - self.config.followup.status_field, - ], - sort=self.config.followup.sort, - ) - last_followup: dict[Any, datetime] = {} - for followup in followup_rows: - followed_at = _parse_datetime( - _first_text( - followup, - [self.config.followup.date_field, *FOLLOWUP_DATE_KEYS], - ), - self.config.timezone, - ) - if followed_at is None: - continue - for customer_id in _relation_ids( - followup.get(self.config.followup.customer_field), - self.config.customer.id_field, - ): - previous = last_followup.get(customer_id) - if previous is None or followed_at > previous: - last_followup[customer_id] = followed_at - - stale: list[dict[str, Any]] = [] - for customer in customer_rows: - customer_id = customer.get(self.config.customer.id_field) - last = last_followup.get(customer_id) - days_since = (as_of_date - last.astimezone(ZoneInfo(self.config.timezone)).date()).days if last else None - if days_since is not None and days_since < inactive_days: - continue - stale.append( - { - "customer": self._compact_customer(customer), - "last_followup_at": (last.astimezone(ZoneInfo(self.config.timezone)).isoformat() if last else None), - "days_since_last_followup": days_since, - "never_followed_up": last is None, - } - ) - stale.sort( - key=lambda item: ( - item["days_since_last_followup"] is not None, - -(item["days_since_last_followup"] or 0), - str(item["customer"].get("name") or ""), - ) - ) - selected = stale[offset : offset + limit] - result = _paginated_rows(selected, len(stale), limit, offset) - result.update( - { - "as_of": as_of_date.isoformat(), - "inactive_days": inactive_days, - "timezone": self.config.timezone, - } - ) - warnings: list[str] = [] - truncation: dict[str, Any] = {} - if customers_truncation: - warnings.append("customer scan was truncated") - truncation["customers"] = customers_truncation - if followups_truncation: - warnings.append("follow-up scan was truncated") - truncation["followups"] = followups_truncation - if warnings: - result["data_quality_warnings"] = warnings - result["truncation"] = truncation - return result - - def list_contacts( - self, - customer_id: Any | None = None, - query: str | None = None, - limit: int = 20, - offset: int = 0, - include_details: bool = True, - include_raw: bool = False, - ) -> dict[str, Any]: - limit, offset = _normalize_pagination(limit, offset) - filter_value = build_and_filter( - [ - build_relation_id_condition(self.config.contact.customer_field, customer_id), - build_search_filter(query, self.config.contact.search_fields), - ] - ) - result = self._list_with_projection_fallback( - self.config.contact.resource, - limit=limit, - offset=offset, - filter=filter_value, - fields=( - (self.config.contact.fields or []) - if include_raw - else self._contact_compact_fields(include_details=include_details) - ), - sort=self.config.contact.sort, - ) - result["results"] = self._compact_contacts( - result["results"], include_details=include_details, include_raw=include_raw - ) - result["timezone"] = self.config.timezone - return result - - def list_followups( - self, - customer_id: Any | None = None, - contact_id: Any | None = None, - owner: str | None = None, - team: str | None = None, - project_id: Any | None = None, - followup_type: str | None = None, - record_status: str | None = None, - date_from: str | None = None, - 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) - if owner_error: - return {"error": owner_error} - team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) - if team_error: - return {"error": team_error} - filter_value = build_and_filter( - [ - build_relation_id_condition(self.config.followup.customer_field, customer_id), - build_relation_id_condition(self.config.followup.contact_field, contact_id), - build_relation_id_condition(self.config.followup.owner_field, owner_id), - build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids), - build_relation_id_condition(self.config.followup.project_field, project_id), - build_eq_condition(self.config.followup.type_field, followup_type), - build_eq_condition(self.config.followup.status_field, record_status), - build_date_range_condition(self.config.followup.date_field, date_from, date_to), - ] - ) - result = self._list_with_projection_fallback( - self.config.followup.resource, - limit=limit, - offset=offset, - filter=filter_value, - fields=(self.config.followup.fields or []) if include_raw else self._followup_compact_fields(), - sort=self.config.followup.sort, - ) - result["results"] = self._compact_followups(result["results"], include_raw=include_raw) - result["timezone"] = self.config.timezone - return result - - def _list_records_window( - self, - resource: str, - *, - limit: int, - offset: int, - **kwargs: Any, - ) -> dict[str, Any]: - page = _page_from_offset(limit, offset) - intra_page_offset = offset % limit - response = self.client.list_records( - resource, - **kwargs, - page=page, - page_size=limit, - ) - rows = self._data(response) - selected = rows[intra_page_offset:] - if intra_page_offset and len(rows) == limit and len(selected) < limit: - next_response = self.client.list_records( - resource, - **kwargs, - page=page + 1, - page_size=limit, - ) - selected.extend(self._data(next_response)[: limit - len(selected)]) - selected = selected[:limit] - total = _total(response, offset + len(selected)) - return _paginated_rows(selected, total, limit, offset) - - def _scan_records( - self, - resource: str, - *, - page_size: int = MAX_PAGE_SIZE, - max_records: int = MAX_SCAN_RECORDS, - **kwargs: Any, - ) -> tuple[list[dict[str, Any]], dict[str, int] | None]: - rows: list[dict[str, Any]] = [] - page = 1 - total: int | None = None - while len(rows) < max_records: - response = self.client.list_records( - resource, - **kwargs, - page=page, - page_size=page_size, - ) - page_rows = self._data(response) - if total is None: - total = _total(response, len(page_rows)) - rows.extend(page_rows) - if not page_rows or len(page_rows) < page_size or len(rows) >= total: - break - page += 1 - selected = rows[:max_records] - if total is None or len(selected) >= total: - return selected, None - return selected, { - "total": total, - "returned": len(selected), - "limit": max_records, - "next_offset": len(selected), - } - - def _project_fields(self) -> list[str]: - project = self.config.project - scalars = [ - project.id_field, - project.name_field, - project.customer_field, - project.inquiry_date_field, - project.progress_field, - project.applications_field, - project.part_numbers_field, - project.period_usage_field, - project.details_field, - project.attachment_field, - "createdAt", - "updatedAt", - ] - projections = [ - *self._projection(project.customer_relation_field, self._customer_spec(), None), - *self._projection(project.contact_field, self._contact_spec(), None), - ] - return _fields_with_projections(scalars, projections) - - def _compact_project(self, row: Mapping[str, Any], *, include_raw: bool) -> dict[str, Any]: - project = self.config.project - progress = row.get(project.progress_field) - progress_labels = {value: label for label, value in project.progress_values.items()} - compact: dict[str, Any] = { - "id": row.get(project.id_field), - "name": row.get(project.name_field), - "customer_id": row.get(project.customer_field), - "customers": _relation_refs(row, [project.customer_relation_field], self._customer_spec()), - "inquiry_date": row.get(project.inquiry_date_field), - "stage": progress_labels.get(str(progress), progress) if progress is not None else None, - "applications": row.get(project.applications_field), - "part_numbers": row.get(project.part_numbers_field), - "period_usage": row.get(project.period_usage_field), - "details": row.get(project.details_field), - "contacts": _relation_refs(row, [project.contact_field], self._contact_spec()), - "createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone), - "updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone), - } - compact = _prune(compact) - if include_raw: - compact["raw"] = dict(row) - return compact - - def _compact_project_update(self, row: Mapping[str, Any]) -> dict[str, Any]: - creators = _relation_refs(row, ["createdBy"], self._user_spec()) - return _prune( - { - "id": row.get("id"), - "updated_at": _beijing_datetime( - row.get(self.config.project.update_date_field) or row.get("createdAt"), - self.config.timezone, - ), - "content": row.get(self.config.project.update_content_field), - "creator": creators[0] if creators else None, - } - ) - - def _safe_metadata(self) -> dict[str, Any]: - collections = { - "customers": self.config.customer.resource, - "contacts": self.config.contact.resource, - "followups": self.config.followup.resource, - "teams": self.config.team.resource, - "projects": self.config.project.resource, - "project_updates": self.config.project.update_resource, - } - fields: dict[str, list[dict[str, Any]]] = {} - field_errors: dict[str, dict[str, str]] = {} - for label, collection_name in collections.items(): - try: - response = self.client.list_collection_fields( - collection_name, - page_size=500, - paginate=False, - ) - fields[label] = self._data(response) - except Exception as error: - field_errors[label] = _metadata_error(error) - return {"collections": collections, "fields": fields, "field_errors": field_errors} - - def _team_matches(self, team: str) -> list[dict[str, Any]]: - normalized = team.strip() - if not normalized: - return [] - rows = self._data( - self.client.list_records( - self.config.team.resource, - filter=build_search_filter(normalized, [self.config.team.name_field]), - fields=self._team_fields(), - page=1, - page_size=50, - ) - ) - exact = [ - row - for row in rows - if str(row.get(self.config.team.name_field) or "").strip().casefold() == normalized.casefold() - ] - return exact or rows - - def _resolve_team_member_ids_or_error( - self, - team: str | None, - ) -> tuple[list[Any] | None, dict[str, Any] | None]: - normalized = (team or "").strip() - if not normalized: - return None, None - matches = self._team_matches(normalized) - candidates = [self._compact_team(row, include_members=False, formal_members_only=True) for row in matches[:10]] - if not matches: - return None, { - "code": "team_not_found", - "message": f"No team matched {normalized!r}", - "query": normalized, - "candidates": [], - } - if len(matches) > 1: - return None, { - "code": "team_ambiguous", - "message": f"Multiple teams matched {normalized!r}", - "query": normalized, - "candidates": candidates, - } - members = self._team_users(matches[0], self.config.team.member_field, formal_only=True) - member_ids = [ - row.get(self.config.user.id_field) for row in members if row.get(self.config.user.id_field) is not None - ] - if not member_ids: - return None, { - "code": "team_has_no_formal_members", - "message": f"Team {normalized!r} has no formal members", - "query": normalized, - "candidates": candidates, - } - return member_ids, None - - def _team_users( - self, - team_row: Mapping[str, Any], - relation_field: str, - *, - formal_only: bool, - ) -> list[dict[str, Any]]: - related = _relation_rows(team_row.get(relation_field)) - user_ids = [ - row.get(self.config.user.id_field) for row in related if row.get(self.config.user.id_field) is not None - ] - if not user_ids: - return [] - conditions: list[dict[str, Any] | None] = [ - {self.config.user.id_field: {"$in": user_ids}}, - ] - if formal_only: - conditions.append( - build_eq_condition( - self.config.team.user_status_field, - self.config.team.formal_user_status_value, - ) - ) - fields = _unique_strings( - [ - *(self.config.user.fields or []), - self.config.team.user_status_field, - ] - ) - users, _truncation = self._scan_records( - self.config.user.resource, - filter=build_and_filter(conditions), - fields=fields, - page_size=min(MAX_PAGE_SIZE, max(1, len(user_ids))), - max_records=len(user_ids), - ) - return users - - def _compact_team( - self, - row: Mapping[str, Any], - *, - include_members: bool, - formal_members_only: bool, - ) -> dict[str, Any]: - managers = self._team_users(row, self.config.team.manager_field, formal_only=False) - raw_members = _relation_rows(row.get(self.config.team.member_field)) - result: dict[str, Any] = { - "id": row.get(self.config.team.id_field), - "name": row.get(self.config.team.name_field), - "manager_names": [ - candidate["label"] - for manager in managers - if (candidate := _format_user_candidate(manager, self.config.user.id_field)).get("label") - ], - "member_count": len(raw_members), - } - if include_members: - members = self._team_users( - row, - self.config.team.member_field, - formal_only=formal_members_only, - ) - result["members"] = [ - { - **_format_user_candidate(member, self.config.user.id_field), - "status": member.get(self.config.team.user_status_field), - } - for member in members - ] - result["returned_member_count"] = len(members) - result["formal_members_only"] = formal_members_only - return result - - def _owner_matches(self, owner: str) -> list[dict[str, Any]]: - normalized = owner.strip() - if not normalized: - return [] - rows = self._data( - self.client.list_records( - self.config.user.resource, - filter=build_search_filter(normalized, self.config.user.match_fields), - fields=self.config.user.fields, - page=1, - page_size=50, - ) - ) - return _pick_user_matches(rows, normalized, self.config.user.match_fields or []) - - def _resolve_owner_id_or_error(self, owner: str | None) -> tuple[Any | None, dict[str, Any] | None]: - normalized = (owner or "").strip() - if not normalized: - return None, None - matches = self._owner_matches(normalized) - candidates = [_format_user_candidate(row, self.config.user.id_field) for row in matches[:10]] - if not matches: - return None, { - "code": "owner_not_found", - "message": f"No user matched owner {normalized!r}", - "query": normalized, - "candidates": [], - } - if len(matches) > 1: - return None, { - "code": "owner_ambiguous", - "message": f"Multiple users matched owner {normalized!r}", - "query": normalized, - "candidates": candidates, - } - user_id = matches[0].get(self.config.user.id_field) - if user_id is None or user_id == "": - return None, { - "code": "owner_invalid", - "message": f"Matched owner {normalized!r} has no {self.config.user.id_field!r}", - "query": normalized, - "candidates": candidates, - } - return user_id, None - - def _resolve_owner_id(self, owner: str | None) -> Any | None: - owner_id, error = self._resolve_owner_id_or_error(owner) - if error: - raise ValueError(str(error["message"])) - return owner_id - - def _count(self, resource: str, id_field: str, filter_value: Mapping[str, Any]) -> int: - response = self.client.query_records( - resource, - measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}], - filter=filter_value, - limit=1, - ) - return _first_numeric(self._data(response), "count") - - def _group_count( - self, - resource: str, - id_field: str, - dimension_field: str | list[str], - filter_value: Mapping[str, Any], - ) -> list[dict[str, Any]]: - if not dimension_field: - return [] - dimension_path = _field_path(dimension_field) - dimension_alias = "_".join(dimension_path) - rows: list[dict[str, Any]] = [] - batch_size = MAX_PAGE_SIZE - offset = 0 - while True: - response = self.client.query_records( - resource, - dimensions=[{"field": dimension_path, "alias": dimension_alias}], - measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}], - orders=[{"field": dimension_path, "alias": dimension_alias, "order": "asc"}], - filter=filter_value, - limit=batch_size, - offset=offset, - ) - batch = self._data(response) - rows.extend(batch) - if len(batch) < batch_size: - break - if len(rows) >= MAX_SCAN_RECORDS: - raise NbCommandError( - f"Aggregation produced at least {MAX_SCAN_RECORDS} buckets; add filters to narrow the result" - ) - offset += batch_size - return rows - - def _decorate_status_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - return _decorate_dimension_counts( - rows, - preferred_keys=[self.config.customer.status_field, "kh_progress", "status"], - labels=CUSTOMER_STATUS_LABELS, - ) - - def _decorate_source_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - return _decorate_dimension_counts( - rows, - preferred_keys=[self.config.customer.source_field, "f_225h6n6v654", "source"], - labels={}, - ) - - def _decorate_owner_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - owner_alias = "_".join(_field_path([self.config.customer.owner_field, "id"])) - decorated: list[dict[str, Any]] = [] - for row in rows: - value, key = _dimension_value(row, [owner_alias, "owner_id", "kehu_followup_person_id"]) - item = dict(row) - if value is not None: - if key is not None and key not in {"owner_id", "value"}: - item.pop(key, None) - item["owner_id"] = value - decorated.append(item) - return decorated - - 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_name_candidates(self) -> list[str]: - return [*(self.config.customer.name_fields or []), "name", "title"] - - def _customer_compact_fields(self, *, detail: bool = False) -> list[str]: - customer = self.config.customer - scalars = _compact_field_list( - customer.fields, - [ - customer.id_field, - *self._customer_name_candidates(), - customer.english_name_field, - customer.territorial_name_field, - customer.status_field, - customer.source_field, - "createdAt", - "updatedAt", - *(customer.detail_fields.values() if detail else []), - ], - ) - projections = [ - *self._projection(customer.owner_field, self._user_spec(), customer.fields), - *self._projection(customer.manager_field, self._user_spec(), customer.fields), - ] - return _fields_with_projections(scalars, projections) - - def _compact_customer( - self, - row: Mapping[str, Any], - *, - detail: bool = False, - include_raw: bool = False, - ) -> dict[str, Any]: - customer = self.config.customer - identifier = row.get(customer.id_field) - name = _first_text(row, self._customer_name_candidates()) - if name is None and identifier is not None: - name = f"{UNKNOWN_NAME_PREFIX}{identifier}" - english_name = _first_text(row, [customer.english_name_field, "english_name"]) - territorial_name = _first_text(row, [customer.territorial_name_field, "territorial_name"]) - status = _first_value(row, [customer.status_field, "status"]) - compact: dict[str, Any] = { - "id": identifier, - "name": name, - # Only worth repeating when they carry information the name does not. - "english_name": english_name if english_name != name else None, - "territorial_name": territorial_name if territorial_name != name else None, - "status": CUSTOMER_STATUS_LABELS.get(status, str(status) if status is not None else None), - "source": _first_value(row, [customer.source_field, "source"]), - "owners": _relation_refs(row, [customer.owner_field], self._user_spec()), - "managers": _relation_refs(row, [customer.manager_field], self._user_spec()), - "createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone), - "updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone), - } - if detail: - for output_key, source_field in customer.detail_fields.items(): - if output_key not in compact: - compact[output_key] = row.get(source_field) - compact = _prune(compact) - if include_raw: - compact["raw"] = dict(row) - return compact - - def _compact_contacts( - self, - rows: list[dict[str, Any]], - *, - include_details: bool = True, - include_raw: bool = False, - ) -> list[dict[str, Any]]: - return [self._compact_contact(row, include_details=include_details, include_raw=include_raw) for row in rows] - - def _contact_name_candidates(self) -> list[str]: - return [*(self.config.contact.name_fields or []), "name", "title"] - - def _contact_compact_fields(self, *, include_details: bool = True) -> list[str]: - contact = self.config.contact - scalars = _compact_field_list( - contact.fields, - [ - contact.id_field, - *self._contact_name_candidates(), - contact.title_field, - "position", - *([contact.phone_field, "phone", contact.email_field, "email"] if include_details else []), - "createdAt", - "updatedAt", - ], - ) - projections = self._projection(contact.customer_field, self._customer_spec(), contact.fields) - return _fields_with_projections(scalars, projections) - - def _compact_contact( - self, - row: Mapping[str, Any], - *, - include_details: bool = True, - include_raw: bool = False, - ) -> dict[str, Any]: - contact = self.config.contact - compact: dict[str, Any] = { - "id": row.get(contact.id_field), - "name": _first_text(row, self._contact_name_candidates()), - "title": _first_text(row, [contact.title_field, "title", "position"]), - "customers": _relation_refs(row, [contact.customer_field], self._customer_spec()), - "createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone), - "updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone), - } - if include_details: - compact["phone"] = _first_text(row, [contact.phone_field, "phone"]) - compact["email"] = _first_text(row, [contact.email_field, "email"]) - compact = _prune(compact) - 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_content_fields(self) -> list[str]: - return [ - self.config.followup.content_field, - *(self.config.followup.search_fields or []), - *FOLLOWUP_CONTENT_KEYS, - ] - - def _followup_compact_fields(self) -> list[str]: - followup = self.config.followup - scalars = _compact_field_list( - followup.fields, - [ - followup.id_field, - followup.date_field, - *FOLLOWUP_DATE_KEYS, - *self._followup_content_fields(), - followup.type_field, - followup.status_field, - followup.location_field, - ], - ) - projections = [ - *self._projection(followup.customer_field, self._customer_spec(), followup.fields), - *self._projection(followup.contact_field, self._contact_spec(), followup.fields), - *self._projection(followup.project_field, self._project_spec(), followup.fields), - *self._projection(followup.owner_field, self._user_spec(), followup.fields), - *self._projection("createdBy", self._user_spec(), followup.fields), - ] - return _fields_with_projections(scalars, projections) - - def _compact_followup(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]: - followup = self.config.followup - summary = self._followup_summary(row) - participants = summary["participants"] - followup_type = row.get(followup.type_field) - record_status = row.get(followup.status_field) - compact: dict[str, Any] = { - "id": row.get(followup.id_field), - "followedAt": _beijing_datetime(summary["followedAt"], self.config.timezone), - "customers": summary["customers"], - "contacts": summary["contacts"], - "participants": participants, - "projects": summary["projects"], - "type": FOLLOWUP_TYPE_LABELS.get(str(followup_type), followup_type) if followup_type is not None else None, - "status": ( - FOLLOWUP_STATUS_LABELS.get(str(record_status), record_status) if record_status is not None else None - ), - "location": _first_text(row, [followup.location_field]), - "content": summary["content"], - } - # A record is usually submitted by one of its own participants; naming the - # submitter again in that case is pure repetition, so it only shows up when - # it actually tells the reader something new. - creator = summary["creator"] - if creator and creator.get("id") not in _ref_ids(participants): - compact["creator"] = creator - compact = _prune(compact) - if include_raw: - compact["raw"] = dict(row) - return compact - - def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]: - followup = self.config.followup - date_fields = [followup.date_field, *FOLLOWUP_DATE_KEYS] - # owner_fields drives *filtering* and may include createdBy; participants - # are only ever the follow-up person relation (the contribution basis). - participant_fields = [followup.owner_field] - creators = _relation_refs(row, ["createdBy"], self._user_spec()) - return { - "content": _first_text(row, self._followup_content_fields()), - "followedAt": _first_text(row, date_fields), - "customers": _relation_refs(row, [followup.customer_field], self._customer_spec()), - "contacts": _relation_refs(row, [followup.contact_field], self._contact_spec()), - "participants": _relation_refs(row, participant_fields, self._user_spec()), - "creator": creators[0] if creators else None, - "projects": _relation_refs(row, [followup.project_field], self._project_spec()), - } - - @staticmethod - def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]: - return _data(response) - - @staticmethod - def _single(response: Mapping[str, Any]) -> Any: - data = response.get("data") - if isinstance(data, list): - return data[0] if data else None - return data - - -def _page_from_offset(limit: int, offset: int) -> int: - bounded_limit = max(1, int(limit)) - bounded_offset = max(0, int(offset)) - return bounded_offset // bounded_limit + 1 - - -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) - except (TypeError, ValueError): - return default - - -def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]: - data = response.get("data") - if isinstance(data, list): - return [item for item in data if isinstance(item, dict)] - if isinstance(data, dict): - for key in ("data", "rows", "results"): - nested = data.get(key) - if isinstance(nested, list): - return [item for item in nested if isinstance(item, dict)] - return [] - - -def _total(response: Mapping[str, Any], fallback: int) -> int: - meta = response.get("meta") - if isinstance(meta, dict): - for key in ("count", "total", "totalCount"): - value = meta.get(key) - if isinstance(value, int): - return value - data = response.get("data") - if isinstance(data, dict): - for key in ("count", "total", "totalCount"): - value = data.get(key) - if isinstance(value, int): - return value - return fallback - - -def _paginated_response(response: Mapping[str, Any], limit: int, offset: int) -> dict[str, Any]: - rows = _data(response) - total = _total(response, len(rows)) - return _paginated_rows(rows, total, limit, offset) - - -def _paginated_rows(rows: list[dict[str, Any]], total: int, limit: int, offset: int) -> dict[str, Any]: - next_offset = offset + len(rows) - return { - "results": rows, - "total": total, - "returned": len(rows), - "limit": limit, - "offset": offset, - "has_more": bool(rows) and next_offset < total, - "next_offset": next_offset if next_offset < total else None, - } - - -def _pagination_metadata(result: Mapping[str, Any]) -> dict[str, Any]: - return {key: result.get(key) for key in ("total", "returned", "limit", "offset", "has_more", "next_offset")} - - -def _beijing_datetime(value: Any, timezone: str) -> Any: - if not isinstance(value, str) or not value.strip(): - return value - normalized = value.strip() - if len(normalized) == 10: - try: - datetime.strptime(normalized, "%Y-%m-%d") - except ValueError: - return value - return normalized - candidate = normalized[:-1] + "+00:00" if normalized.endswith("Z") else normalized - try: - parsed = datetime.fromisoformat(candidate) - except ValueError: - return value - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - try: - return parsed.astimezone(ZoneInfo(timezone)).isoformat() - except (ValueError, KeyError): - return parsed.astimezone(ZoneInfo("Asia/Shanghai")).isoformat() - - -def _parse_datetime(value: Any, timezone: str) -> datetime | None: - normalized = _beijing_datetime(value, timezone) - if not isinstance(normalized, str) or len(normalized) == 10: - return None - try: - return datetime.fromisoformat(normalized) - except ValueError: - return None - - -def _first_numeric(rows: list[dict[str, Any]], preferred_key: str) -> int: - for row in rows: - value = row.get(preferred_key) - if isinstance(value, (int, float)): - return int(value) - for nested_value in row.values(): - if isinstance(nested_value, (int, float)): - return int(nested_value) - return 0 - - -def _decorate_dimension_counts( - rows: list[dict[str, Any]], - *, - preferred_keys: list[str], - labels: Mapping[Any, str], -) -> list[dict[str, Any]]: - decorated: list[dict[str, Any]] = [] - for row in rows: - value, key = _dimension_value(row, preferred_keys) - item = dict(row) - if value is not None: - label = labels.get(value) or str(value) - # The raw dimension key is dropped once normalized -- keeping it meant - # every bucket carried the same value under an internal field code too. - if key is not None and key not in {"value", "label"}: - item.pop(key, None) - item["value"] = value - item["label"] = label - decorated.append(item) - decorated.sort(key=lambda item: (_is_blank(item.get("value")), -_count_value(item), str(item.get("label") or ""))) - return decorated - - -def _decorate_id_counts( - rows: list[dict[str, Any]], - *, - preferred_keys: list[str], - output_key: str, -) -> list[dict[str, Any]]: - decorated: list[dict[str, Any]] = [] - for row in rows: - value, key = _dimension_value(row, preferred_keys) - item = dict(row) - if value is not None: - if key is not None and key != output_key: - item.pop(key, None) - item[output_key] = value - decorated.append(item) - decorated.sort(key=lambda item: (-_count_value(item), str(item.get(output_key) or ""))) - return decorated - - -def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple[Any | None, str | None]: - for key in preferred_keys: - if key in row: - return row[key], key - for key, value in row.items(): - if key == "count": - continue - if not isinstance(value, (dict, list)): - return value, str(key) - 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) - if value is None or value == "": - continue - if isinstance(value, (dict, list)): - continue - return str(value) - return None - - -def _prune(mapping: Mapping[str, Any]) -> dict[str, Any]: - """Drop keys whose value carries no information. - - An absent key means "empty", never "unknown" -- callers reconciling records - should not have to skip past a screenful of nulls and empty lists. ``id`` is - always kept so a record stays addressable even when nothing else resolved. - """ - pruned: dict[str, Any] = {} - for key, value in mapping.items(): - if key == "id" or not _is_empty(value): - pruned[key] = value - return pruned - - -def _is_empty(value: Any) -> bool: - if value is None: - return True - if isinstance(value, str): - return value.strip() == "" - if isinstance(value, (list, tuple, dict, set)): - return len(value) == 0 - return False - - -def _ref_from_mapping(item: Mapping[str, Any], spec: LabelSpec) -> dict[str, Any]: - """Render one related record as ``{"id": ..., "name": ...}``. - - Deliberately never falls back to the raw id as the *name*: a customer whose - name fields are all empty used to surface as e.g. ``"4249"``, which reads - like a real name. It becomes ``"#4249"`` instead -- unmistakably an id, and - a signal that the source record needs its name filled in. - """ - ident = item.get(spec.id_field) - name = _first_text(item, list(spec.name_fields)) - if name is None and ident is not None: - name = f"{UNKNOWN_NAME_PREFIX}{ident}" - return _prune({"id": ident, "name": name}) - - -def _relation_refs(row: Mapping[str, Any], fields: list[str], spec: LabelSpec) -> list[dict[str, Any]]: - refs: list[dict[str, Any]] = [] - seen: set[Any] = set() - for field in _unique_strings(fields): - for item in _relation_rows(row.get(field)): - ref = _ref_from_mapping(item, spec) - if not ref: - continue - key = ref.get("id", ref.get("name")) - if key in seen: - continue - seen.add(key) - refs.append(ref) - return refs - - -def _ref_names(refs: list[dict[str, Any]]) -> list[str]: - return [str(ref["name"]) for ref in refs if ref.get("name")] - - -def _ref_ids(refs: list[dict[str, Any]]) -> list[Any]: - return [ref["id"] for ref in refs if ref.get("id") is not None] - - -def _fields_with_projections(scalars: list[str], projections: list[str]) -> list[str]: - """Combine scalar field names with dotted relation projections. - - A relation requested bare (``gjjl_client``) next to a dotted projection - (``gjjl_client.id``) makes NocoBase expand the whole related row again, which - is exactly the bloat the projections exist to avoid -- so any scalar that is - also a projection root is dropped. - """ - roots = {path.split(".", 1)[0] for path in projections} - kept = [name for name in _unique_strings(scalars) if name not in roots] - return kept + _unique_strings(projections) - - -def _relation_rows(value: Any) -> list[dict[str, Any]]: - if isinstance(value, Mapping): - nested = value.get("data") - if isinstance(nested, list): - return [dict(item) for item in nested if isinstance(item, Mapping)] - return [dict(value)] - if isinstance(value, list): - return [dict(item) for item in value if isinstance(item, Mapping)] - return [] - - -def _relation_ids(value: Any, id_field: str = "id") -> list[Any]: - return [row.get(id_field) for row in _relation_rows(value) if row.get(id_field) is not None] - - -def _unique_values(values: list[Any]) -> list[Any]: - unique: list[Any] = [] - seen: set[Any] = set() - for value in values: - if value in seen: - continue - seen.add(value) - unique.append(value) - return unique - - -def _unique_strings(values: list[str]) -> list[str]: - unique: list[str] = [] - for value in values: - normalized = str(value or "").strip() - if normalized and normalized not in unique: - unique.append(normalized) - return unique - - -def _metadata_error(error: Exception) -> dict[str, str]: - message = str(error) - if "403" in message or "No permissions" in message: - return { - "code": "metadata_permission_required", - "message": message, - "hint": ( - "Grant the agent role read-only permission for " - "`data-modeling collections fields list`, then retry crm_schema(include_fields=true)." - ), - } - return {"code": "metadata_error", "message": message, "hint": "Check NocoBase metadata API reachability and ACL."} - - -def _field_list(value: Any) -> list[dict[str, Any]]: - return [item for item in value or [] if isinstance(item, dict)] - - -def _field_names(fields: list[dict[str, Any]]) -> list[str]: - return [str(field["name"]) for field in fields if field.get("name")] - - -def _field_path(field: str | list[str]) -> list[str]: - if isinstance(field, str): - return [part for part in field.split(".") if part] - return [str(part) for part in field if str(part)] - - -def _field_text(field: dict[str, Any]) -> str: - pieces = [ - str(field.get("name") or ""), - str(field.get("title") or ""), - str(field.get("interface") or ""), - str(field.get("type") or ""), - ] - ui_schema = field.get("uiSchema") - if isinstance(ui_schema, dict): - pieces.append(str(ui_schema.get("title") or "")) - return " ".join(pieces).lower() - - -def _find_named_field(fields: list[dict[str, Any]], names: list[str]) -> str | None: - available = {str(field.get("name")) for field in fields if field.get("name")} - for name in names: - if name in available: - return name - return None - - -def _existing_field_names(fields: list[dict[str, Any]], names: list[str]) -> list[str]: - available = {str(field.get("name")) for field in fields if field.get("name")} - return [name for name in names if name in available] - - -def _configured_or_search_fields(fields: list[dict[str, Any]], preferred_names: list[str]) -> list[str]: - preferred = _existing_field_names(fields, preferred_names) - if preferred: - return preferred - return _search_fields(fields) - - -def _search_fields(fields: list[dict[str, Any]]) -> list[str]: - preferred_interfaces = {"input", "textarea", "email", "phone", "url"} - preferred_types = {"string", "text"} - names = [ - str(field["name"]) - for field in fields - if field.get("name") - and ( - str(field.get("interface") or "").lower() in preferred_interfaces - or str(field.get("type") or "").lower() in preferred_types - ) - ] - if names: - return names[:4] - fallback = [str(field["name"]) for field in fields if field.get("name") and str(field.get("name")) != "id"] - return fallback[:2] - - -def _find_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None: - lowered = [needle.lower() for needle in needles] - for field in fields: - name = field.get("name") - if not name: - continue - text = _field_text(field) - if any(needle in text for needle in lowered): - return str(name) - return None - - -def _relation_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None: - lowered = [needle.lower() for needle in needles] - for field in fields: - if not field.get("name"): - continue - if str(field.get("type") or "").lower() not in {"belongsto", "belongstomany", "hasmany", "hasone"}: - continue - text = _field_text(field) - if any(needle in text for needle in lowered): - return str(field["name"]) - return None - - -def _date_field(fields: list[dict[str, Any]]) -> str | None: - preferred = _find_field(fields, ["跟进日期", "跟进时间", "follow", "date", "time"]) - if preferred: - return preferred - for field in fields: - if field.get("name") and str(field.get("interface") or "").lower() in {"date", "datetime", "createdat"}: - return str(field["name"]) - if field.get("name") and str(field.get("type") or "").lower() in {"date", "datetime"}: - return str(field["name"]) - return None - - -def _template_sort(date_field: str | None, fallback: list[str] | None) -> list[str] | None: - if date_field: - sort = [f"-{date_field}"] - if date_field != "createdAt": - sort.append("-createdAt") - return sort - return fallback - - -def _pick_user_matches(rows: list[dict[str, Any]], query: str, match_fields: list[str]) -> list[dict[str, Any]]: - normalized = query.casefold() - exact = [ - row for row in rows if any(str(row.get(field) or "").strip().casefold() == normalized for field in match_fields) - ] - if exact: - return exact - fuzzy = [ - row for row in rows if any(normalized in str(row.get(field) or "").strip().casefold() for field in match_fields) - ] - return fuzzy - - -def _user_label(row: dict[str, Any]) -> str: - for key in ("nickname", "username", "email", "f_yh_english_name", "id"): - value = row.get(key) - if value is not None and value != "": - return str(value) - return "" - - -def _format_user_candidate(row: dict[str, Any], id_field: str) -> dict[str, Any]: - candidate: dict[str, Any] = { - "id": row.get(id_field), - "label": _user_label(row), - } - for key in ("username", "nickname", "email", "f_yh_english_name"): - value = row.get(key) - if value is not None and value != "": - candidate[key] = value - return candidate +from .crm_analytics import CrmAnalyticsMixin +from .crm_core import CrmCoreMixin +from .crm_directory import CrmDirectoryMixin +from .crm_metadata import CrmMetadataMixin +from .crm_presenters import CrmPresentersMixin +from .crm_records import CrmRecordsMixin +from .crm_reporting import CrmReportingMixin +from .crm_shared import PROJECTION_FALLBACK_WARNING, _prune + + +class CrmService( + CrmCoreMixin, + CrmMetadataMixin, + CrmDirectoryMixin, + CrmRecordsMixin, + CrmPresentersMixin, + CrmAnalyticsMixin, + CrmReportingMixin, +): + """Facade for CRM metadata, directory, record, and analytics operations.""" + + +__all__ = ["CrmService", "PROJECTION_FALLBACK_WARNING", "_prune"] diff --git a/nocobase_crm_mcp/crm_analytics.py b/nocobase_crm_mcp/crm_analytics.py new file mode 100644 index 0000000..7c88b35 --- /dev/null +++ b/nocobase_crm_mcp/crm_analytics.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_schema_helpers import _field_path +from .crm_shared import ( + CUSTOMER_STATUS_LABELS, + FOLLOWUP_STATUS_LABELS, + FOLLOWUP_TYPE_LABELS, + MAX_PAGE_SIZE, + MAX_SCAN_RECORDS, + Any, + Mapping, + NbCommandError, + _decorate_dimension_counts, + _dimension_value, + _first_numeric, + build_and_filter, + build_date_range_condition, + build_relation_id_condition, +) + + +class CrmAnalyticsMixin(CrmMixin): + def crm_overview( + self, + date_from: str | None = None, + date_to: str | None = None, + owner: str | None = None, + ) -> dict[str, Any]: + owner_id, owner_error = self._resolve_owner_id_or_error(owner) + if owner_error: + return {"error": owner_error} + customer_filter = build_and_filter([build_relation_id_condition(self.config.customer.owner_field, owner_id)]) + followup_filter = build_and_filter( + [ + build_relation_id_condition(self.config.followup.owner_field, owner_id), + build_date_range_condition(self.config.followup.date_field, date_from, date_to), + ] + ) + totals = { + "customers": self._count(self.config.customer.resource, self.config.customer.id_field, customer_filter), + "contacts": self._count(self.config.contact.resource, self.config.contact.id_field, {}), + "followups": self._count(self.config.followup.resource, self.config.followup.id_field, followup_filter), + } + overview: dict[str, Any] = { + "totals": totals, + "customer_status": self._group_count( + self.config.customer.resource, + self.config.customer.id_field, + self.config.customer.status_field, + customer_filter, + ), + "customer_source": self._group_count( + self.config.customer.resource, + self.config.customer.id_field, + self.config.customer.source_field, + customer_filter, + ), + "customer_owner": self._group_count( + self.config.customer.resource, + self.config.customer.id_field, + [self.config.customer.owner_field, "id"], + customer_filter, + ), + "recent_followups": self._compact_followups( + self._data( + self.client.list_records( + self.config.followup.resource, + filter=followup_filter, + fields=self._followup_compact_fields(), + sort=self.config.followup.sort, + page=1, + page_size=10, + ) + ) + ), + } + overview["customer_status"] = self._decorate_status_counts(overview["customer_status"]) + overview["customer_source"] = self._decorate_source_counts(overview["customer_source"]) + overview["customer_owner"] = self._decorate_owner_counts(overview["customer_owner"]) + return overview + + def crm_filter_options(self, owner_query: str | None = None) -> dict[str, Any]: + owner_candidates: list[dict[str, Any]] = [] + if (owner_query or "").strip(): + owner_candidates = self.resolve_owner(owner_query or "")["results"] + return { + "customer_status": self._decorate_status_counts( + self._group_count( + self.config.customer.resource, + self.config.customer.id_field, + self.config.customer.status_field, + {}, + ) + ), + "customer_source": self._decorate_source_counts( + self._group_count( + self.config.customer.resource, + self.config.customer.id_field, + self.config.customer.source_field, + {}, + ) + ), + # Follow-up rows render type and status as labels only, so the raw + # codes the filters expect are published here instead. + "followup_type": [{"value": value, "label": label} for value, label in FOLLOWUP_TYPE_LABELS.items()], + "followup_record_status": [ + {"value": value, "label": label} for value, label in FOLLOWUP_STATUS_LABELS.items() + ], + "owner_candidates": owner_candidates, + "max_limit": MAX_PAGE_SIZE, + } + + def _count(self, resource: str, id_field: str, filter_value: Mapping[str, Any]) -> int: + response = self.client.query_records( + resource, + measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}], + filter=filter_value, + limit=1, + ) + return _first_numeric(self._data(response), "count") + + def _group_count( + self, + resource: str, + id_field: str, + dimension_field: str | list[str], + filter_value: Mapping[str, Any], + ) -> list[dict[str, Any]]: + if not dimension_field: + return [] + dimension_path = _field_path(dimension_field) + dimension_alias = "_".join(dimension_path) + rows: list[dict[str, Any]] = [] + batch_size = MAX_PAGE_SIZE + offset = 0 + while True: + response = self.client.query_records( + resource, + dimensions=[{"field": dimension_path, "alias": dimension_alias}], + measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}], + orders=[{"field": dimension_path, "alias": dimension_alias, "order": "asc"}], + filter=filter_value, + limit=batch_size, + offset=offset, + ) + batch = self._data(response) + rows.extend(batch) + if len(batch) < batch_size: + break + if len(rows) >= MAX_SCAN_RECORDS: + raise NbCommandError( + f"Aggregation produced at least {MAX_SCAN_RECORDS} buckets; add filters to narrow the result" + ) + offset += batch_size + return rows + + def _decorate_status_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return _decorate_dimension_counts( + rows, + preferred_keys=[self.config.customer.status_field, "kh_progress", "status"], + labels=CUSTOMER_STATUS_LABELS, + ) + + def _decorate_source_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return _decorate_dimension_counts( + rows, + preferred_keys=[self.config.customer.source_field, "f_225h6n6v654", "source"], + labels={}, + ) + + def _decorate_owner_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + owner_alias = "_".join(_field_path([self.config.customer.owner_field, "id"])) + decorated: list[dict[str, Any]] = [] + for row in rows: + value, key = _dimension_value(row, [owner_alias, "owner_id", "kehu_followup_person_id"]) + item = dict(row) + if value is not None: + if key is not None and key not in {"owner_id", "value"}: + item.pop(key, None) + item["owner_id"] = value + decorated.append(item) + return decorated diff --git a/nocobase_crm_mcp/crm_core.py b/nocobase_crm_mcp/crm_core.py new file mode 100644 index 0000000..939c2ea --- /dev/null +++ b/nocobase_crm_mcp/crm_core.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_shared import ( + PROJECTION_FALLBACK_WARNING, + Any, + CrmConfig, + LabelSpec, + NbClient, + NbCommandError, + _fields_with_projections, + _unique_strings, +) + + +class CrmCoreMixin(CrmMixin): + def __init__(self, config: CrmConfig, client: NbClient) -> None: + self.config = config + self.client = client + + def _customer_spec(self) -> LabelSpec: + return LabelSpec(self.config.customer.id_field, tuple(self.config.customer.name_fields or ())) + + def _contact_spec(self) -> LabelSpec: + return LabelSpec(self.config.contact.id_field, tuple(self.config.contact.name_fields or ())) + + def _user_spec(self) -> LabelSpec: + return LabelSpec(self.config.user.id_field, tuple(self.config.user.label_fields or ())) + + def _project_spec(self) -> LabelSpec: + return LabelSpec(self.config.project.id_field, tuple(self.config.project.name_fields or ())) + + def _team_fields(self) -> list[str]: + # Only the related user ids are needed: _team_users re-queries the users + # collection for their details, and _compact_team just counts members. + team = self.config.team + id_only = LabelSpec(self.config.user.id_field, ()) + return _fields_with_projections( + [team.id_field, team.name_field], + [ + *self._projection(team.manager_field, id_only, None), + *self._projection(team.member_field, id_only, None), + ], + ) + + def _projection(self, relation: str | None, spec: LabelSpec, configured_fields: list[str] | None) -> list[str]: + """Build dotted field paths that fetch only a relation's id and name. + + Without this NocoBase expands every related row in full -- junction table + rows, audit user objects, long free text -- none of which is rendered. + The configured-fields whitelist still applies, just to the relation root + rather than to each dotted path. + """ + if not relation: + return [] + if configured_fields and relation not in configured_fields: + return [] + return [f"{relation}.{name}" for name in spec.projection_fields()] + + def _list_with_projection_fallback(self, resource: str, *, fields: list[str], **kwargs: Any) -> dict[str, Any]: + """Run a projected list query, degrading to relation roots if it is rejected. + + A misconfigured ``nameFields`` entry would otherwise turn every read into + a hard failure; falling back keeps the tool usable and says why. + """ + try: + return self._list_records_window(resource, fields=fields, **kwargs) + except NbCommandError: + flat = _unique_strings([path.split(".", 1)[0] for path in fields]) + if flat == _unique_strings(fields): + raise + result = self._list_records_window(resource, fields=flat, **kwargs) + result.setdefault("data_quality_warnings", []).append(PROJECTION_FALLBACK_WARNING) + return result diff --git a/nocobase_crm_mcp/crm_directory.py b/nocobase_crm_mcp/crm_directory.py new file mode 100644 index 0000000..d74459c --- /dev/null +++ b/nocobase_crm_mcp/crm_directory.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_shared import ( + MAX_PAGE_SIZE, + Any, + Mapping, + NbCommandError, + _data, + _format_user_candidate, + _normalize_pagination, + _pick_user_matches, + _ref_from_mapping, + _relation_rows, + _unique_strings, + _unique_values, + build_and_filter, + build_eq_condition, + build_search_filter, +) + + +class CrmDirectoryMixin(CrmMixin): + def _attach_customer_names( + self, + rows: list[dict[str, Any]], + *, + id_key: str = "customer_id", + max_lookups: int = MAX_PAGE_SIZE, + ) -> list[dict[str, Any]]: + """Fill in customer names for id-keyed aggregation buckets. + + Grouping runs on the aggregation endpoint and only ever yields ids, which + left callers reading rows identified by a bare number. Counts are never + touched -- this only adds a name. + """ + identifiers = _unique_values([row.get(id_key) for row in rows if row.get(id_key) is not None]) + if not identifiers: + return rows + names: dict[Any, str] = {} + try: + lookup = self.client.list_records( + self.config.customer.resource, + filter={self.config.customer.id_field: {"$in": identifiers[:max_lookups]}}, + fields=_unique_strings([self.config.customer.id_field, *(self.config.customer.name_fields or [])]), + page=1, + page_size=max_lookups, + ) + except NbCommandError: + return rows + for customer in _data(lookup): + ref = _ref_from_mapping(customer, self._customer_spec()) + if ref.get("id") is not None and ref.get("name"): + names[ref["id"]] = str(ref["name"]) + for row in rows: + name = names.get(row.get(id_key)) + if name: + row["customer_name"] = name + return rows + + def resolve_owner(self, query: str) -> dict[str, Any]: + normalized = (query or "").strip() + if not normalized: + return {"query": "", "results": [], "count": 0} + rows = self._owner_matches(normalized) + results = [_format_user_candidate(row, self.config.user.id_field) for row in rows] + return {"query": normalized, "results": results, "count": len(results)} + + def list_teams( + self, + query: str | None = None, + *, + include_members: bool = False, + formal_members_only: bool = True, + limit: int = 20, + offset: int = 0, + ) -> dict[str, Any]: + limit, offset = _normalize_pagination(limit, offset) + result = self._list_records_window( + self.config.team.resource, + limit=limit, + offset=offset, + filter=build_and_filter([build_search_filter(query, [self.config.team.name_field])]), + fields=self._team_fields(), + sort=[self.config.team.name_field], + ) + result["results"] = [ + self._compact_team( + row, + include_members=include_members, + formal_members_only=formal_members_only, + ) + for row in result["results"] + ] + return result + + def resolve_team(self, query: str) -> dict[str, Any]: + normalized = (query or "").strip() + if not normalized: + return {"query": "", "results": [], "count": 0} + rows = self._team_matches(normalized) + results = [self._compact_team(row, include_members=False, formal_members_only=True) for row in rows] + return {"query": normalized, "results": results, "count": len(results)} + + def get_team( + self, + team_id: Any, + *, + include_members: bool = True, + formal_members_only: bool = True, + ) -> dict[str, Any]: + row = self._single( + self.client.get_record( + self.config.team.resource, + team_id, + fields=self._team_fields(), + ) + ) + if not isinstance(row, Mapping): + return {"error": {"code": "team_not_found", "message": f"No team matched id {team_id!r}"}} + return { + "team": self._compact_team( + row, + include_members=include_members, + formal_members_only=formal_members_only, + ) + } + + def _team_matches(self, team: str) -> list[dict[str, Any]]: + normalized = team.strip() + if not normalized: + return [] + rows = self._data( + self.client.list_records( + self.config.team.resource, + filter=build_search_filter(normalized, [self.config.team.name_field]), + fields=self._team_fields(), + page=1, + page_size=50, + ) + ) + exact = [ + row + for row in rows + if str(row.get(self.config.team.name_field) or "").strip().casefold() == normalized.casefold() + ] + return exact or rows + + def _resolve_team_member_ids_or_error( + self, + team: str | None, + ) -> tuple[list[Any] | None, dict[str, Any] | None]: + normalized = (team or "").strip() + if not normalized: + return None, None + matches = self._team_matches(normalized) + candidates = [self._compact_team(row, include_members=False, formal_members_only=True) for row in matches[:10]] + if not matches: + return None, { + "code": "team_not_found", + "message": f"No team matched {normalized!r}", + "query": normalized, + "candidates": [], + } + if len(matches) > 1: + return None, { + "code": "team_ambiguous", + "message": f"Multiple teams matched {normalized!r}", + "query": normalized, + "candidates": candidates, + } + members = self._team_users(matches[0], self.config.team.member_field, formal_only=True) + member_ids = [ + row.get(self.config.user.id_field) for row in members if row.get(self.config.user.id_field) is not None + ] + if not member_ids: + return None, { + "code": "team_has_no_formal_members", + "message": f"Team {normalized!r} has no formal members", + "query": normalized, + "candidates": candidates, + } + return member_ids, None + + def _team_users( + self, + team_row: Mapping[str, Any], + relation_field: str, + *, + formal_only: bool, + ) -> list[dict[str, Any]]: + related = _relation_rows(team_row.get(relation_field)) + user_ids = [ + row.get(self.config.user.id_field) for row in related if row.get(self.config.user.id_field) is not None + ] + if not user_ids: + return [] + conditions: list[dict[str, Any] | None] = [ + {self.config.user.id_field: {"$in": user_ids}}, + ] + if formal_only: + conditions.append( + build_eq_condition( + self.config.team.user_status_field, + self.config.team.formal_user_status_value, + ) + ) + fields = _unique_strings( + [ + *(self.config.user.fields or []), + self.config.team.user_status_field, + ] + ) + users, _truncation = self._scan_records( + self.config.user.resource, + filter=build_and_filter(conditions), + fields=fields, + page_size=min(MAX_PAGE_SIZE, max(1, len(user_ids))), + max_records=len(user_ids), + ) + return users + + def _compact_team( + self, + row: Mapping[str, Any], + *, + include_members: bool, + formal_members_only: bool, + ) -> dict[str, Any]: + managers = self._team_users(row, self.config.team.manager_field, formal_only=False) + raw_members = _relation_rows(row.get(self.config.team.member_field)) + result: dict[str, Any] = { + "id": row.get(self.config.team.id_field), + "name": row.get(self.config.team.name_field), + "manager_names": [ + candidate["label"] + for manager in managers + if (candidate := _format_user_candidate(manager, self.config.user.id_field)).get("label") + ], + "member_count": len(raw_members), + } + if include_members: + members = self._team_users( + row, + self.config.team.member_field, + formal_only=formal_members_only, + ) + result["members"] = [ + { + **_format_user_candidate(member, self.config.user.id_field), + "status": member.get(self.config.team.user_status_field), + } + for member in members + ] + result["returned_member_count"] = len(members) + result["formal_members_only"] = formal_members_only + return result + + def _owner_matches(self, owner: str) -> list[dict[str, Any]]: + normalized = owner.strip() + if not normalized: + return [] + rows = self._data( + self.client.list_records( + self.config.user.resource, + filter=build_search_filter(normalized, self.config.user.match_fields), + fields=self.config.user.fields, + page=1, + page_size=50, + ) + ) + return _pick_user_matches(rows, normalized, self.config.user.match_fields or []) + + def _resolve_owner_id_or_error(self, owner: str | None) -> tuple[Any | None, dict[str, Any] | None]: + normalized = (owner or "").strip() + if not normalized: + return None, None + matches = self._owner_matches(normalized) + candidates = [_format_user_candidate(row, self.config.user.id_field) for row in matches[:10]] + if not matches: + return None, { + "code": "owner_not_found", + "message": f"No user matched owner {normalized!r}", + "query": normalized, + "candidates": [], + } + if len(matches) > 1: + return None, { + "code": "owner_ambiguous", + "message": f"Multiple users matched owner {normalized!r}", + "query": normalized, + "candidates": candidates, + } + user_id = matches[0].get(self.config.user.id_field) + if user_id is None or user_id == "": + return None, { + "code": "owner_invalid", + "message": f"Matched owner {normalized!r} has no {self.config.user.id_field!r}", + "query": normalized, + "candidates": candidates, + } + return user_id, None + + def _resolve_owner_id(self, owner: str | None) -> Any | None: + owner_id, error = self._resolve_owner_id_or_error(owner) + if error: + raise ValueError(str(error["message"])) + return owner_id diff --git a/nocobase_crm_mcp/crm_metadata.py b/nocobase_crm_mcp/crm_metadata.py new file mode 100644 index 0000000..6615a20 --- /dev/null +++ b/nocobase_crm_mcp/crm_metadata.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_schema_helpers import ( + _configured_or_search_fields, + _date_field, + _existing_field_names, + _field_list, + _field_names, + _find_field, + _find_named_field, + _metadata_error, + _relation_field, + _template_sort, +) +from .crm_shared import Any + + +class CrmMetadataMixin(CrmMixin): + def crm_health(self) -> dict[str, Any]: + health = self.client.health(self.config.customer.resource, self.config.customer.id_field) + configured = { + "env": self.config.env_name, + "data_source": self.config.data_source, + "collections": { + "customers": self.config.customer.resource, + "contacts": self.config.contact.resource, + "followups": self.config.followup.resource, + "teams": self.config.team.resource, + "projects": self.config.project.resource, + "project_updates": self.config.project.update_resource, + }, + } + return {"ok": bool(health.get("ok")), "nb": health, "configured": configured} + + def crm_schema(self, include_fields: bool = False) -> dict[str, Any]: + schema: dict[str, Any] = { + "env": self.config.env_name, + "data_source": self.config.data_source, + "collections": { + "customers": self.config.customer.__dict__, + "contacts": self.config.contact.__dict__, + "followups": self.config.followup.__dict__, + "users": self.config.user.__dict__, + "teams": self.config.team.__dict__, + "projects": self.config.project.__dict__, + }, + } + if include_fields: + schema["metadata"] = self._safe_metadata() + return schema + + def crm_config_template(self) -> dict[str, Any]: + metadata = self._safe_metadata() + fields_by_kind = metadata.get("fields", {}) if isinstance(metadata, dict) else {} + customer_fields = _field_list(fields_by_kind.get("customers")) + contact_fields = _field_list(fields_by_kind.get("contacts")) + followup_fields = _field_list(fields_by_kind.get("followups")) + customer_resource = self.config.customer.resource + contact_resource = self.config.contact.resource + return { + "env": self.config.env_name, + "dataSource": self.config.data_source, + "users": { + "resource": self.config.user.resource, + "fields": self.config.user.fields, + "matchFields": self.config.user.match_fields, + "labelFields": self.config.user.label_fields, + }, + "collections": { + "customers": { + "resource": customer_resource, + "fields": _field_names(customer_fields), + "searchFields": _configured_or_search_fields( + customer_fields, + ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"], + ), + # Ordered: the first non-empty one becomes the customer's + # displayed name everywhere it is referenced. + "nameFields": _existing_field_names( + customer_fields, + ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"], + ) + or self.config.customer.name_fields, + "managerField": _find_named_field(customer_fields, ["kehu_manage_person"]) + or self.config.customer.manager_field, + "detailFields": { + key: value + for key, value in self.config.customer.detail_fields.items() + if _find_named_field(customer_fields, [value]) + }, + "statusField": _find_named_field(customer_fields, ["kh_progress"]) + or _find_field(customer_fields, ["进度", "状态", "status"]), + "sourceField": _find_named_field(customer_fields, ["f_225h6n6v654"]) + or _find_field(customer_fields, ["客户来源", "来源", "source"]), + "ownerField": _find_named_field(customer_fields, ["kehu_followup_person"]) + or self.config.customer.owner_field, + "sort": self.config.customer.sort, + }, + "contacts": { + "resource": contact_resource, + "fields": _field_names(contact_fields), + "searchFields": _configured_or_search_fields( + contact_fields, + ["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"], + ), + "nameFields": _existing_field_names(contact_fields, ["f_2zpg91gtmwp"]) + or self.config.contact.name_fields, + "titleField": _find_named_field(contact_fields, ["f_tn8zdm2f938"]) + or self.config.contact.title_field, + "phoneField": _find_named_field(contact_fields, ["khlxr_phone_number"]) + or self.config.contact.phone_field, + "emailField": _find_named_field(contact_fields, ["khlxr_email"]) or self.config.contact.email_field, + "customerField": _find_named_field(contact_fields, ["kehulianxiren_client"]) + or _relation_field(contact_fields, ["公司", "客户"]) + or self.config.contact.customer_field, + "sort": self.config.contact.sort, + }, + "followups": { + "resource": self.config.followup.resource, + "fields": _field_names(followup_fields), + "searchFields": _configured_or_search_fields(followup_fields, ["f_19iplxz0nds"]), + "customerField": _find_named_field(followup_fields, ["gjjl_client"]) + or _relation_field(followup_fields, ["客户"]) + or self.config.followup.customer_field, + "contactField": _find_named_field(followup_fields, ["gjjl_client_contacts"]) + or _relation_field(followup_fields, ["客户联系人", "联系人"]) + or self.config.followup.contact_field, + "ownerField": _find_named_field(followup_fields, ["gjjl_followup_person"]) + or self.config.followup.owner_field, + "ownerFields": _existing_field_names( + followup_fields, + ["gjjl_followup_person", "createdBy"], + ) + or self.config.followup.owner_fields, + "dateField": _find_named_field(followup_fields, ["f_srysmjkwfq1"]) + or _date_field(followup_fields) + or self.config.followup.date_field, + "contentField": _find_named_field(followup_fields, ["f_19iplxz0nds"]) + or self.config.followup.content_field, + "typeField": _find_named_field(followup_fields, ["gjjl_followup_type"]) + or self.config.followup.type_field, + "statusField": _find_named_field(followup_fields, ["f_hskl7gjvos9"]) + or self.config.followup.status_field, + "projectField": _find_named_field(followup_fields, ["f_gjjl_projects"]) + or self.config.followup.project_field, + "locationField": _find_named_field(followup_fields, ["f_b5c4hfi5og6"]) + or self.config.followup.location_field, + "sort": _template_sort( + _find_named_field(followup_fields, ["f_srysmjkwfq1"]) + or _date_field(followup_fields) + or self.config.followup.date_field, + self.config.followup.sort, + ), + }, + }, + "metadata": { + "field_errors": metadata.get("field_errors", {}) if isinstance(metadata, dict) else {}, + "note": "Review inferred owner/status/source/date fields before using this template in production.", + }, + } + + def _safe_metadata(self) -> dict[str, Any]: + collections = { + "customers": self.config.customer.resource, + "contacts": self.config.contact.resource, + "followups": self.config.followup.resource, + "teams": self.config.team.resource, + "projects": self.config.project.resource, + "project_updates": self.config.project.update_resource, + } + fields: dict[str, list[dict[str, Any]]] = {} + field_errors: dict[str, dict[str, str]] = {} + for label, collection_name in collections.items(): + try: + response = self.client.list_collection_fields( + collection_name, + page_size=500, + paginate=False, + ) + fields[label] = self._data(response) + except Exception as error: + field_errors[label] = _metadata_error(error) + return {"collections": collections, "fields": fields, "field_errors": field_errors} diff --git a/nocobase_crm_mcp/crm_mixin.py b/nocobase_crm_mcp/crm_mixin.py new file mode 100644 index 0000000..5e76818 --- /dev/null +++ b/nocobase_crm_mcp/crm_mixin.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Any + +from .config import CrmConfig +from .nb_client import NbClient + + +class CrmMixin: + """Typing context shared by composable CRM service mixins.""" + + config: CrmConfig + client: NbClient + + def __getattr__(self, name: str) -> Any: + raise AttributeError(name) diff --git a/nocobase_crm_mcp/crm_presenters.py b/nocobase_crm_mcp/crm_presenters.py new file mode 100644 index 0000000..3163f9e --- /dev/null +++ b/nocobase_crm_mcp/crm_presenters.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_shared import ( + CUSTOMER_STATUS_LABELS, + FOLLOWUP_CONTENT_KEYS, + FOLLOWUP_DATE_KEYS, + FOLLOWUP_STATUS_LABELS, + FOLLOWUP_TYPE_LABELS, + UNKNOWN_NAME_PREFIX, + Any, + Mapping, + _beijing_datetime, + _compact_field_list, + _fields_with_projections, + _first_text, + _first_value, + _prune, + _ref_ids, + _relation_refs, +) + + +class CrmPresentersMixin(CrmMixin): + def _project_fields(self) -> list[str]: + project = self.config.project + scalars = [ + project.id_field, + project.name_field, + project.customer_field, + project.inquiry_date_field, + project.progress_field, + project.applications_field, + project.part_numbers_field, + project.period_usage_field, + project.details_field, + project.attachment_field, + "createdAt", + "updatedAt", + ] + projections = [ + *self._projection(project.customer_relation_field, self._customer_spec(), None), + *self._projection(project.contact_field, self._contact_spec(), None), + ] + return _fields_with_projections(scalars, projections) + + def _compact_project(self, row: Mapping[str, Any], *, include_raw: bool) -> dict[str, Any]: + project = self.config.project + progress = row.get(project.progress_field) + progress_labels = {value: label for label, value in project.progress_values.items()} + compact: dict[str, Any] = { + "id": row.get(project.id_field), + "name": row.get(project.name_field), + "customer_id": row.get(project.customer_field), + "customers": _relation_refs(row, [project.customer_relation_field], self._customer_spec()), + "inquiry_date": row.get(project.inquiry_date_field), + "stage": progress_labels.get(str(progress), progress) if progress is not None else None, + "applications": row.get(project.applications_field), + "part_numbers": row.get(project.part_numbers_field), + "period_usage": row.get(project.period_usage_field), + "details": row.get(project.details_field), + "contacts": _relation_refs(row, [project.contact_field], self._contact_spec()), + "createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone), + "updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone), + } + compact = _prune(compact) + if include_raw: + compact["raw"] = dict(row) + return compact + + def _compact_project_update(self, row: Mapping[str, Any]) -> dict[str, Any]: + creators = _relation_refs(row, ["createdBy"], self._user_spec()) + return _prune( + { + "id": row.get("id"), + "updated_at": _beijing_datetime( + row.get(self.config.project.update_date_field) or row.get("createdAt"), + self.config.timezone, + ), + "content": row.get(self.config.project.update_content_field), + "creator": creators[0] if creators else None, + } + ) + + 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_name_candidates(self) -> list[str]: + return [*(self.config.customer.name_fields or []), "name", "title"] + + def _customer_compact_fields(self, *, detail: bool = False) -> list[str]: + customer = self.config.customer + scalars = _compact_field_list( + customer.fields, + [ + customer.id_field, + *self._customer_name_candidates(), + customer.english_name_field, + customer.territorial_name_field, + customer.status_field, + customer.source_field, + "createdAt", + "updatedAt", + *(customer.detail_fields.values() if detail else []), + ], + ) + projections = [ + *self._projection(customer.owner_field, self._user_spec(), customer.fields), + *self._projection(customer.manager_field, self._user_spec(), customer.fields), + ] + return _fields_with_projections(scalars, projections) + + def _compact_customer( + self, + row: Mapping[str, Any], + *, + detail: bool = False, + include_raw: bool = False, + ) -> dict[str, Any]: + customer = self.config.customer + identifier = row.get(customer.id_field) + name = _first_text(row, self._customer_name_candidates()) + if name is None and identifier is not None: + name = f"{UNKNOWN_NAME_PREFIX}{identifier}" + english_name = _first_text(row, [customer.english_name_field, "english_name"]) + territorial_name = _first_text(row, [customer.territorial_name_field, "territorial_name"]) + status = _first_value(row, [customer.status_field, "status"]) + compact: dict[str, Any] = { + "id": identifier, + "name": name, + # Only worth repeating when they carry information the name does not. + "english_name": english_name if english_name != name else None, + "territorial_name": territorial_name if territorial_name != name else None, + "status": CUSTOMER_STATUS_LABELS.get(status, str(status) if status is not None else None), + "source": _first_value(row, [customer.source_field, "source"]), + "owners": _relation_refs(row, [customer.owner_field], self._user_spec()), + "managers": _relation_refs(row, [customer.manager_field], self._user_spec()), + "createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone), + "updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone), + } + if detail: + for output_key, source_field in customer.detail_fields.items(): + if output_key not in compact: + compact[output_key] = row.get(source_field) + compact = _prune(compact) + if include_raw: + compact["raw"] = dict(row) + return compact + + def _compact_contacts( + self, + rows: list[dict[str, Any]], + *, + include_details: bool = True, + include_raw: bool = False, + ) -> list[dict[str, Any]]: + return [self._compact_contact(row, include_details=include_details, include_raw=include_raw) for row in rows] + + def _contact_name_candidates(self) -> list[str]: + return [*(self.config.contact.name_fields or []), "name", "title"] + + def _contact_compact_fields(self, *, include_details: bool = True) -> list[str]: + contact = self.config.contact + scalars = _compact_field_list( + contact.fields, + [ + contact.id_field, + *self._contact_name_candidates(), + contact.title_field, + "position", + *([contact.phone_field, "phone", contact.email_field, "email"] if include_details else []), + "createdAt", + "updatedAt", + ], + ) + projections = self._projection(contact.customer_field, self._customer_spec(), contact.fields) + return _fields_with_projections(scalars, projections) + + def _compact_contact( + self, + row: Mapping[str, Any], + *, + include_details: bool = True, + include_raw: bool = False, + ) -> dict[str, Any]: + contact = self.config.contact + compact: dict[str, Any] = { + "id": row.get(contact.id_field), + "name": _first_text(row, self._contact_name_candidates()), + "title": _first_text(row, [contact.title_field, "title", "position"]), + "customers": _relation_refs(row, [contact.customer_field], self._customer_spec()), + "createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone), + "updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone), + } + if include_details: + compact["phone"] = _first_text(row, [contact.phone_field, "phone"]) + compact["email"] = _first_text(row, [contact.email_field, "email"]) + compact = _prune(compact) + 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_content_fields(self) -> list[str]: + return [ + self.config.followup.content_field, + *(self.config.followup.search_fields or []), + *FOLLOWUP_CONTENT_KEYS, + ] + + def _followup_compact_fields(self) -> list[str]: + followup = self.config.followup + scalars = _compact_field_list( + followup.fields, + [ + followup.id_field, + followup.date_field, + *FOLLOWUP_DATE_KEYS, + *self._followup_content_fields(), + followup.type_field, + followup.status_field, + followup.location_field, + ], + ) + projections = [ + *self._projection(followup.customer_field, self._customer_spec(), followup.fields), + *self._projection(followup.contact_field, self._contact_spec(), followup.fields), + *self._projection(followup.project_field, self._project_spec(), followup.fields), + *self._projection(followup.owner_field, self._user_spec(), followup.fields), + *self._projection("createdBy", self._user_spec(), followup.fields), + ] + return _fields_with_projections(scalars, projections) + + def _compact_followup(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]: + followup = self.config.followup + summary = self._followup_summary(row) + participants = summary["participants"] + followup_type = row.get(followup.type_field) + record_status = row.get(followup.status_field) + compact: dict[str, Any] = { + "id": row.get(followup.id_field), + "followedAt": _beijing_datetime(summary["followedAt"], self.config.timezone), + "customers": summary["customers"], + "contacts": summary["contacts"], + "participants": participants, + "projects": summary["projects"], + "type": FOLLOWUP_TYPE_LABELS.get(str(followup_type), followup_type) if followup_type is not None else None, + "status": ( + FOLLOWUP_STATUS_LABELS.get(str(record_status), record_status) if record_status is not None else None + ), + "location": _first_text(row, [followup.location_field]), + "content": summary["content"], + } + # A record is usually submitted by one of its own participants; naming the + # submitter again in that case is pure repetition, so it only shows up when + # it actually tells the reader something new. + creator = summary["creator"] + if creator and creator.get("id") not in _ref_ids(participants): + compact["creator"] = creator + compact = _prune(compact) + if include_raw: + compact["raw"] = dict(row) + return compact + + def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]: + followup = self.config.followup + date_fields = [followup.date_field, *FOLLOWUP_DATE_KEYS] + # owner_fields drives *filtering* and may include createdBy; participants + # are only ever the follow-up person relation (the contribution basis). + participant_fields = [followup.owner_field] + creators = _relation_refs(row, ["createdBy"], self._user_spec()) + return { + "content": _first_text(row, self._followup_content_fields()), + "followedAt": _first_text(row, date_fields), + "customers": _relation_refs(row, [followup.customer_field], self._customer_spec()), + "contacts": _relation_refs(row, [followup.contact_field], self._contact_spec()), + "participants": _relation_refs(row, participant_fields, self._user_spec()), + "creator": creators[0] if creators else None, + "projects": _relation_refs(row, [followup.project_field], self._project_spec()), + } diff --git a/nocobase_crm_mcp/crm_records.py b/nocobase_crm_mcp/crm_records.py new file mode 100644 index 0000000..941b584 --- /dev/null +++ b/nocobase_crm_mcp/crm_records.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_shared import ( + MAX_PAGE_SIZE, + MAX_SCAN_RECORDS, + Any, + Mapping, + _data, + _normalize_pagination, + _page_from_offset, + _paginated_rows, + _pagination_metadata, + _relation_refs, + _total, + build_and_filter, + build_date_range_condition, + build_eq_condition, + build_relation_id_condition, + build_relation_id_in_condition, + build_search_filter, +) + + +class CrmRecordsMixin(CrmMixin): + def list_customers( + self, + query: str | None = None, + status: str | None = None, + owner: str | None = None, + team: str | None = None, + 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) + if owner_error: + return {"error": owner_error} + team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) + if team_error: + return {"error": team_error} + filter_value = build_and_filter( + [ + build_eq_condition(self.config.customer.status_field, status), + build_relation_id_condition(self.config.customer.owner_field, owner_id), + build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids), + build_eq_condition(self.config.customer.source_field, source), + build_search_filter(query, self.config.customer.search_fields), + ] + ) + result = self._list_with_projection_fallback( + self.config.customer.resource, + limit=limit, + offset=offset, + filter=filter_value, + fields=(self.config.customer.fields or []) if include_raw else self._customer_compact_fields(), + sort=self.config.customer.sort, + ) + result["results"] = self._compact_customers(result["results"], include_raw=include_raw) + result["timezone"] = self.config.timezone + return result + + def get_customer( + self, + customer_id: Any, + include_contacts: bool = True, + include_recent_followups: bool = True, + followup_limit: int = 10, + include_raw: bool = False, + ) -> dict[str, Any]: + fields = self.config.customer.fields if include_raw else self._customer_compact_fields(detail=True) + row = self._single( + self.client.get_record( + self.config.customer.resource, + customer_id, + fields=fields, + ) + ) + if not isinstance(row, Mapping): + return { + "error": { + "code": "customer_not_found", + "message": f"No customer matches id {customer_id!r}.", + "hint": "Use list_customers to find the customer id.", + "retryable": False, + } + } + result: dict[str, Any] = { + "customer": self._compact_customer(row, detail=True, include_raw=include_raw), + } + if include_contacts: + # Phone and email are deliberately withheld here: a customer overview + # does not need personal contact details. list_contacts still serves them. + contacts_result = self.list_contacts( + customer_id=customer_id, + limit=50, + offset=0, + include_details=False, + ) + result["contacts"] = contacts_result["results"] + result["contacts_pagination"] = _pagination_metadata(contacts_result) + if include_recent_followups: + followups_result = self.list_followups( + customer_id=customer_id, + limit=followup_limit, + offset=0, + include_raw=include_raw, + ) + result["recent_followups"] = followups_result["results"] + result["recent_followups_pagination"] = _pagination_metadata(followups_result) + result["timezone"] = self.config.timezone + return result + + def list_projects( + self, + *, + customer_id: Any | None = None, + team: str | None = None, + stage: str | None = None, + limit: int = 20, + offset: int = 0, + include_raw: bool = False, + ) -> dict[str, Any]: + limit, offset = _normalize_pagination(limit, offset) + team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) + if team_error: + return {"error": team_error} + stage_value = stage + if stage: + if stage in self.config.project.progress_values: + stage_value = self.config.project.progress_values[stage] + elif stage not in self.config.project.progress_values.values(): + return { + "error": { + "code": "invalid_project_stage", + "message": "stage must be one of the configured project progress labels or values", + } + } + filter_value = build_and_filter( + [ + build_eq_condition(self.config.project.customer_field, customer_id), + build_relation_id_in_condition( + [self.config.project.customer_relation_field, self.config.customer.owner_field, "id"], + team_member_ids, + ), + build_eq_condition(self.config.project.progress_field, stage_value), + ] + ) + result = self._list_with_projection_fallback( + self.config.project.resource, + limit=limit, + offset=offset, + filter=filter_value, + fields=self._project_fields(), + sort=["-updatedAt"], + ) + result["results"] = [self._compact_project(row, include_raw=include_raw) for row in result["results"]] + result["timezone"] = self.config.timezone + return result + + def get_project( + self, + project_id: Any, + *, + include_contacts: bool = True, + include_followups: bool = True, + include_updates: bool = True, + ) -> dict[str, Any]: + row = self._single( + self.client.get_record( + self.config.project.resource, + project_id, + fields=self._project_fields(), + ) + ) + if not isinstance(row, Mapping): + return {"error": {"code": "project_not_found", "message": f"No project matched id {project_id!r}"}} + project = self._compact_project(row, include_raw=False) + result: dict[str, Any] = {"project": project, "timezone": self.config.timezone} + if include_contacts: + # Was returning raw contact rows, phone and email included. + result["contacts"] = _relation_refs(row, [self.config.project.contact_field], self._contact_spec()) + if include_followups: + followups_result = self.list_followups(project_id=project_id, limit=100, offset=0) + result["followups"] = followups_result["results"] + result["followups_pagination"] = _pagination_metadata(followups_result) + if include_updates: + updates, truncation = self._scan_records( + self.config.project.update_resource, + filter=build_and_filter( + [build_relation_id_condition(self.config.project.update_project_field, project_id)] + ), + fields=[ + "id", + self.config.project.update_project_field, + self.config.project.update_date_field, + self.config.project.update_content_field, + self.config.project.update_attachment_field, + "createdBy", + "createdAt", + ], + sort=[self.config.project.update_date_field, "createdAt"], + ) + result["updates"] = [self._compact_project_update(update) for update in updates] + if truncation: + result["data_quality_warnings"] = ["project updates were truncated at the scan limit"] + result["truncation"] = {"updates": truncation} + return result + + def list_contacts( + self, + customer_id: Any | None = None, + query: str | None = None, + limit: int = 20, + offset: int = 0, + include_details: bool = True, + include_raw: bool = False, + ) -> dict[str, Any]: + limit, offset = _normalize_pagination(limit, offset) + filter_value = build_and_filter( + [ + build_relation_id_condition(self.config.contact.customer_field, customer_id), + build_search_filter(query, self.config.contact.search_fields), + ] + ) + result = self._list_with_projection_fallback( + self.config.contact.resource, + limit=limit, + offset=offset, + filter=filter_value, + fields=( + (self.config.contact.fields or []) + if include_raw + else self._contact_compact_fields(include_details=include_details) + ), + sort=self.config.contact.sort, + ) + result["results"] = self._compact_contacts( + result["results"], include_details=include_details, include_raw=include_raw + ) + result["timezone"] = self.config.timezone + return result + + def list_followups( + self, + customer_id: Any | None = None, + contact_id: Any | None = None, + owner: str | None = None, + team: str | None = None, + project_id: Any | None = None, + followup_type: str | None = None, + record_status: str | None = None, + date_from: str | None = None, + 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) + if owner_error: + return {"error": owner_error} + team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) + if team_error: + return {"error": team_error} + filter_value = build_and_filter( + [ + build_relation_id_condition(self.config.followup.customer_field, customer_id), + build_relation_id_condition(self.config.followup.contact_field, contact_id), + build_relation_id_condition(self.config.followup.owner_field, owner_id), + build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids), + build_relation_id_condition(self.config.followup.project_field, project_id), + build_eq_condition(self.config.followup.type_field, followup_type), + build_eq_condition(self.config.followup.status_field, record_status), + build_date_range_condition(self.config.followup.date_field, date_from, date_to), + ] + ) + result = self._list_with_projection_fallback( + self.config.followup.resource, + limit=limit, + offset=offset, + filter=filter_value, + fields=(self.config.followup.fields or []) if include_raw else self._followup_compact_fields(), + sort=self.config.followup.sort, + ) + result["results"] = self._compact_followups(result["results"], include_raw=include_raw) + result["timezone"] = self.config.timezone + return result + + def _list_records_window( + self, + resource: str, + *, + limit: int, + offset: int, + **kwargs: Any, + ) -> dict[str, Any]: + page = _page_from_offset(limit, offset) + intra_page_offset = offset % limit + response = self.client.list_records( + resource, + **kwargs, + page=page, + page_size=limit, + ) + rows = self._data(response) + selected = rows[intra_page_offset:] + if intra_page_offset and len(rows) == limit and len(selected) < limit: + next_response = self.client.list_records( + resource, + **kwargs, + page=page + 1, + page_size=limit, + ) + selected.extend(self._data(next_response)[: limit - len(selected)]) + selected = selected[:limit] + total = _total(response, offset + len(selected)) + return _paginated_rows(selected, total, limit, offset) + + def _scan_records( + self, + resource: str, + *, + page_size: int = MAX_PAGE_SIZE, + max_records: int = MAX_SCAN_RECORDS, + **kwargs: Any, + ) -> tuple[list[dict[str, Any]], dict[str, int] | None]: + rows: list[dict[str, Any]] = [] + page = 1 + total: int | None = None + while len(rows) < max_records: + response = self.client.list_records( + resource, + **kwargs, + page=page, + page_size=page_size, + ) + page_rows = self._data(response) + if total is None: + total = _total(response, len(page_rows)) + rows.extend(page_rows) + if not page_rows or len(page_rows) < page_size or len(rows) >= total: + break + page += 1 + selected = rows[:max_records] + if total is None or len(selected) >= total: + return selected, None + return selected, { + "total": total, + "returned": len(selected), + "limit": max_records, + "next_offset": len(selected), + } + + @staticmethod + def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]: + return _data(response) + + @staticmethod + def _single(response: Mapping[str, Any]) -> Any: + data = response.get("data") + if isinstance(data, list): + return data[0] if data else None + return data diff --git a/nocobase_crm_mcp/crm_reporting.py b/nocobase_crm_mcp/crm_reporting.py new file mode 100644 index 0000000..ec677fc --- /dev/null +++ b/nocobase_crm_mcp/crm_reporting.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +from .crm_mixin import CrmMixin +from .crm_shared import ( + FOLLOWUP_DATE_KEYS, + FOLLOWUP_TYPE_LABELS, + INVALID_FOLLOWUP_STATUS, + Any, + ZoneInfo, + _decorate_dimension_counts, + _decorate_id_counts, + _first_text, + _format_user_candidate, + _int_or_default, + _normalize_pagination, + _paginated_rows, + _parse_datetime, + _ref_names, + _relation_ids, + build_and_filter, + build_date_range_condition, + build_relation_id_condition, + build_relation_id_in_condition, + date, + datetime, +) + + +class CrmReportingMixin(CrmMixin): + def team_crm_overview( + self, + team: str, + *, + date_from: str | None = None, + date_to: str | None = None, + include_member_breakdown: bool = True, + ) -> dict[str, Any]: + matches = self._team_matches((team or "").strip()) + member_ids, error = self._resolve_team_member_ids_or_error(team) + if error: + return {"error": error} + assert member_ids is not None + team_row = matches[0] + members = self._team_users(team_row, self.config.team.member_field, formal_only=True) + customer_filter = build_and_filter( + [build_relation_id_in_condition(self.config.customer.owner_field, member_ids)] + ) + followup_filter = build_and_filter( + [ + build_relation_id_in_condition(self.config.followup.owner_field, member_ids), + build_date_range_condition(self.config.followup.date_field, date_from, date_to), + {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, + ] + ) + project_filter = build_and_filter( + [ + build_relation_id_in_condition( + [self.config.project.customer_relation_field, self.config.customer.owner_field, "id"], + member_ids, + ), + { + self.config.project.progress_field: { + "$ne": self.config.project.progress_values["终止"], + } + }, + ] + ) + result: dict[str, Any] = { + "team": self._compact_team(team_row, include_members=True, formal_members_only=True), + "period": {"date_from": date_from, "date_to": date_to}, + "timezone": self.config.timezone, + "totals": { + "owned_customers": self._count( + self.config.customer.resource, + self.config.customer.id_field, + customer_filter, + ), + "followup_records": self._count( + self.config.followup.resource, + self.config.followup.id_field, + followup_filter, + ), + "active_projects": self._count( + self.config.project.resource, + self.config.project.id_field, + project_filter, + ), + }, + "by_followup_type": self._group_count( + self.config.followup.resource, + self.config.followup.id_field, + self.config.followup.type_field, + followup_filter, + ), + "by_customer": self._group_count( + self.config.followup.resource, + self.config.followup.id_field, + [self.config.followup.customer_field, "id"], + followup_filter, + ), + "by_project_stage": self._group_count( + self.config.project.resource, + self.config.project.id_field, + self.config.project.progress_field, + project_filter, + ), + "contribution_basis": { + "participant_field": self.config.followup.owner_field, + "team_totals": "unique follow-up record IDs", + "member_contribution": "one participation per member per follow-up", + }, + } + if include_member_breakdown: + breakdown: list[dict[str, Any]] = [] + for member in members: + member_id = member.get(self.config.user.id_field) + if member_id is None: + continue + member_customer_filter = build_and_filter( + [build_relation_id_condition(self.config.customer.owner_field, member_id)] + ) + member_followup_filter = build_and_filter( + [ + build_relation_id_condition(self.config.followup.owner_field, member_id), + build_date_range_condition(self.config.followup.date_field, date_from, date_to), + {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, + ] + ) + candidate = _format_user_candidate(member, self.config.user.id_field) + breakdown.append( + { + "member": candidate, + "owned_customers": self._count( + self.config.customer.resource, + self.config.customer.id_field, + member_customer_filter, + ), + "followup_participations": self._count( + self.config.followup.resource, + self.config.followup.id_field, + member_followup_filter, + ), + } + ) + result["by_member"] = sorted( + breakdown, + key=lambda item: (-item["followup_participations"], str(item["member"].get("label") or "")), + ) + result["by_followup_type"] = _decorate_dimension_counts( + result["by_followup_type"], + preferred_keys=[self.config.followup.type_field], + labels=FOLLOWUP_TYPE_LABELS, + ) + result["by_project_stage"] = _decorate_dimension_counts( + result["by_project_stage"], + preferred_keys=[self.config.project.progress_field], + labels={value: label for label, value in self.config.project.progress_values.items()}, + ) + result["by_customer"] = self._attach_customer_names( + _decorate_id_counts( + result["by_customer"], + preferred_keys=[ + f"{self.config.followup.customer_field}_id", + "gjjl_client_id", + ], + output_key="customer_id", + ) + ) + return result + + def get_project_timeline( + self, + project_id: Any, + *, + date_from: str | None = None, + date_to: str | None = None, + ) -> dict[str, Any]: + project_result = self.get_project( + project_id, + include_contacts=False, + include_followups=False, + include_updates=False, + ) + if "error" in project_result: + return project_result + project = project_result["project"] + followup_rows, followups_truncation = self._scan_records( + self.config.followup.resource, + filter=build_and_filter( + [ + build_relation_id_condition(self.config.followup.project_field, project_id), + build_date_range_condition(self.config.followup.date_field, date_from, date_to), + ] + ), + fields=self._followup_compact_fields(), + sort=self.config.followup.sort, + ) + update_rows, updates_truncation = self._scan_records( + self.config.project.update_resource, + filter=build_and_filter( + [ + build_relation_id_condition(self.config.project.update_project_field, project_id), + build_date_range_condition(self.config.project.update_date_field, date_from, date_to), + ] + ), + fields=[ + "id", + self.config.project.update_date_field, + self.config.project.update_content_field, + "createdBy", + "createdAt", + ], + sort=[self.config.project.update_date_field, "createdAt"], + ) + events: list[dict[str, Any]] = [] + for row in followup_rows: + compact = self._compact_followup(row) + events.append( + { + "event_type": "followup", + "occurred_at": compact.get("followedAt"), + "actors": _ref_names(compact.get("participants", [])), + "summary": compact.get("content"), + "source_resource": self.config.followup.resource, + "source_id": compact.get("id"), + } + ) + for row in update_rows: + compact = self._compact_project_update(row) + events.append( + { + "event_type": "project_update", + "occurred_at": compact.get("updated_at"), + "actors": _ref_names([compact["creator"]] if compact.get("creator") else []), + "summary": compact.get("content"), + "source_resource": self.config.project.update_resource, + "source_id": compact.get("id"), + } + ) + events.sort(key=lambda event: str(event.get("occurred_at") or "")) + result = { + "project": project, + "events": events, + "timezone": self.config.timezone, + } + warnings: list[str] = [] + truncation: dict[str, Any] = {} + if followups_truncation: + warnings.append("follow-up events were truncated at the scan limit") + truncation["followups"] = followups_truncation + if updates_truncation: + warnings.append("project update events were truncated at the scan limit") + truncation["updates"] = updates_truncation + if warnings: + result["data_quality_warnings"] = warnings + result["truncation"] = truncation + return result + + def list_stale_customers( + self, + *, + team: str | None = None, + owner: str | None = None, + inactive_days: int = 30, + as_of: str | None = None, + limit: int = 20, + offset: int = 0, + ) -> dict[str, Any]: + limit, offset = _normalize_pagination(limit, offset) + inactive_days = max(1, _int_or_default(inactive_days, 30)) + owner_id, owner_error = self._resolve_owner_id_or_error(owner) + if owner_error: + return {"error": owner_error} + team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) + if team_error: + return {"error": team_error} + try: + as_of_date = date.fromisoformat(as_of) if as_of else datetime.now(ZoneInfo(self.config.timezone)).date() + except ValueError: + return {"error": {"code": "invalid_date", "message": "as_of must use YYYY-MM-DD"}} + + customer_filter = build_and_filter( + [ + build_relation_id_condition(self.config.customer.owner_field, owner_id), + build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids), + ] + ) + followup_filter = build_and_filter( + [ + build_relation_id_condition(self.config.followup.owner_field, owner_id), + build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids), + {self.config.followup.date_field: {"$dateNotAfter": as_of_date.isoformat()}}, + {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, + ] + ) + customer_rows, customers_truncation = self._scan_records( + self.config.customer.resource, + filter=customer_filter, + fields=self._customer_compact_fields(), + sort=self.config.customer.sort, + ) + followup_rows, followups_truncation = self._scan_records( + self.config.followup.resource, + filter=followup_filter, + # This scan can pull thousands of rows and only needs the customer id, + # so the customer relation is projected down to exactly that. + fields=[ + self.config.followup.id_field, + f"{self.config.followup.customer_field}.{self.config.customer.id_field}", + self.config.followup.date_field, + self.config.followup.status_field, + ], + sort=self.config.followup.sort, + ) + last_followup: dict[Any, datetime] = {} + for followup in followup_rows: + followed_at = _parse_datetime( + _first_text( + followup, + [self.config.followup.date_field, *FOLLOWUP_DATE_KEYS], + ), + self.config.timezone, + ) + if followed_at is None: + continue + for customer_id in _relation_ids( + followup.get(self.config.followup.customer_field), + self.config.customer.id_field, + ): + previous = last_followup.get(customer_id) + if previous is None or followed_at > previous: + last_followup[customer_id] = followed_at + + stale: list[dict[str, Any]] = [] + for customer in customer_rows: + customer_id = customer.get(self.config.customer.id_field) + last = last_followup.get(customer_id) + days_since = (as_of_date - last.astimezone(ZoneInfo(self.config.timezone)).date()).days if last else None + if days_since is not None and days_since < inactive_days: + continue + stale.append( + { + "customer": self._compact_customer(customer), + "last_followup_at": (last.astimezone(ZoneInfo(self.config.timezone)).isoformat() if last else None), + "days_since_last_followup": days_since, + "never_followed_up": last is None, + } + ) + stale.sort( + key=lambda item: ( + item["days_since_last_followup"] is not None, + -(item["days_since_last_followup"] or 0), + str(item["customer"].get("name") or ""), + ) + ) + selected = stale[offset : offset + limit] + result = _paginated_rows(selected, len(stale), limit, offset) + result.update( + { + "as_of": as_of_date.isoformat(), + "inactive_days": inactive_days, + "timezone": self.config.timezone, + } + ) + warnings: list[str] = [] + truncation: dict[str, Any] = {} + if customers_truncation: + warnings.append("customer scan was truncated") + truncation["customers"] = customers_truncation + if followups_truncation: + warnings.append("follow-up scan was truncated") + truncation["followups"] = followups_truncation + if warnings: + result["data_quality_warnings"] = warnings + result["truncation"] = truncation + return result diff --git a/nocobase_crm_mcp/crm_schema_helpers.py b/nocobase_crm_mcp/crm_schema_helpers.py new file mode 100644 index 0000000..8bce229 --- /dev/null +++ b/nocobase_crm_mcp/crm_schema_helpers.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import Any + + +def _metadata_error(error: Exception) -> dict[str, str]: + message = str(error) + if "403" in message or "No permissions" in message: + return { + "code": "metadata_permission_required", + "message": message, + "hint": ( + "Grant the agent role read-only permission for " + "`data-modeling collections fields list`, then retry crm_schema(include_fields=true)." + ), + } + return {"code": "metadata_error", "message": message, "hint": "Check NocoBase metadata API reachability and ACL."} + + +def _field_list(value: Any) -> list[dict[str, Any]]: + return [item for item in value or [] if isinstance(item, dict)] + + +def _field_names(fields: list[dict[str, Any]]) -> list[str]: + return [str(field["name"]) for field in fields if field.get("name")] + + +def _field_path(field: str | list[str]) -> list[str]: + if isinstance(field, str): + return [part for part in field.split(".") if part] + return [str(part) for part in field if str(part)] + + +def _field_text(field: dict[str, Any]) -> str: + pieces = [ + str(field.get("name") or ""), + str(field.get("title") or ""), + str(field.get("interface") or ""), + str(field.get("type") or ""), + ] + ui_schema = field.get("uiSchema") + if isinstance(ui_schema, dict): + pieces.append(str(ui_schema.get("title") or "")) + return " ".join(pieces).lower() + + +def _find_named_field(fields: list[dict[str, Any]], names: list[str]) -> str | None: + available = {str(field.get("name")) for field in fields if field.get("name")} + for name in names: + if name in available: + return name + return None + + +def _existing_field_names(fields: list[dict[str, Any]], names: list[str]) -> list[str]: + available = {str(field.get("name")) for field in fields if field.get("name")} + return [name for name in names if name in available] + + +def _configured_or_search_fields(fields: list[dict[str, Any]], preferred_names: list[str]) -> list[str]: + preferred = _existing_field_names(fields, preferred_names) + if preferred: + return preferred + return _search_fields(fields) + + +def _search_fields(fields: list[dict[str, Any]]) -> list[str]: + preferred_interfaces = {"input", "textarea", "email", "phone", "url"} + preferred_types = {"string", "text"} + names = [ + str(field["name"]) + for field in fields + if field.get("name") + and ( + str(field.get("interface") or "").lower() in preferred_interfaces + or str(field.get("type") or "").lower() in preferred_types + ) + ] + if names: + return names[:4] + fallback = [str(field["name"]) for field in fields if field.get("name") and str(field.get("name")) != "id"] + return fallback[:2] + + +def _find_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None: + lowered = [needle.lower() for needle in needles] + for field in fields: + name = field.get("name") + if not name: + continue + text = _field_text(field) + if any(needle in text for needle in lowered): + return str(name) + return None + + +def _relation_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None: + lowered = [needle.lower() for needle in needles] + for field in fields: + if not field.get("name"): + continue + if str(field.get("type") or "").lower() not in {"belongsto", "belongstomany", "hasmany", "hasone"}: + continue + text = _field_text(field) + if any(needle in text for needle in lowered): + return str(field["name"]) + return None + + +def _date_field(fields: list[dict[str, Any]]) -> str | None: + preferred = _find_field(fields, ["跟进日期", "跟进时间", "follow", "date", "time"]) + if preferred: + return preferred + for field in fields: + if field.get("name") and str(field.get("interface") or "").lower() in {"date", "datetime", "createdat"}: + return str(field["name"]) + if field.get("name") and str(field.get("type") or "").lower() in {"date", "datetime"}: + return str(field["name"]) + return None + + +def _template_sort(date_field: str | None, fallback: list[str] | None) -> list[str] | None: + if date_field: + sort = [f"-{date_field}"] + if date_field != "createdAt": + sort.append("-createdAt") + return sort + return fallback + + +__all__ = [ + "_configured_or_search_fields", + "_date_field", + "_existing_field_names", + "_field_list", + "_field_names", + "_field_path", + "_field_text", + "_find_field", + "_find_named_field", + "_metadata_error", + "_relation_field", + "_search_fields", + "_template_sort", +] diff --git a/nocobase_crm_mcp/crm_shared.py b/nocobase_crm_mcp/crm_shared.py new file mode 100644 index 0000000..b298475 --- /dev/null +++ b/nocobase_crm_mcp/crm_shared.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, date, datetime +from typing import Any +from zoneinfo import ZoneInfo + +from .config import CrmConfig +from .filters import ( + build_and_filter, + build_date_range_condition, + build_eq_condition, + build_relation_id_condition, + build_relation_id_in_condition, + build_search_filter, +) +from .nb_client import NbClient, NbCommandError + +__all__ = [ + "Any", + "CUSTOMER_STATUS_LABELS", + "CrmConfig", + "FOLLOWUP_CONTENT_KEYS", + "FOLLOWUP_DATE_KEYS", + "FOLLOWUP_STATUS_LABELS", + "FOLLOWUP_TYPE_LABELS", + "INVALID_FOLLOWUP_STATUS", + "LabelSpec", + "MAX_PAGE_SIZE", + "MAX_SCAN_RECORDS", + "Mapping", + "NbClient", + "NbCommandError", + "PROJECTION_FALLBACK_WARNING", + "UNKNOWN_NAME_PREFIX", + "ZoneInfo", + "_beijing_datetime", + "_compact_field_list", + "_data", + "_decorate_dimension_counts", + "_decorate_id_counts", + "_dimension_value", + "_fields_with_projections", + "_first_numeric", + "_first_text", + "_first_value", + "_format_user_candidate", + "_int_or_default", + "_normalize_pagination", + "_page_from_offset", + "_paginated_rows", + "_pagination_metadata", + "_parse_datetime", + "_pick_user_matches", + "_prune", + "_ref_from_mapping", + "_ref_ids", + "_ref_names", + "_relation_ids", + "_relation_refs", + "_relation_rows", + "_total", + "_unique_strings", + "_unique_values", + "build_and_filter", + "build_date_range_condition", + "build_eq_condition", + "build_relation_id_condition", + "build_relation_id_in_condition", + "build_search_filter", + "date", + "datetime", +] + +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 100 +MAX_SCAN_RECORDS = 5000 +UNKNOWN_NAME_PREFIX = "#" +PROJECTION_FALLBACK_WARNING = "NocoBase rejected relation field projection; check nameFields in the CRM config" + +CUSTOMER_STATUS_LABELS = { + "kh_p_not_start": "未开始", + "kh_p_inprogress": "进行中", + "kh_p_ordered": "已下单", + "kh_p_audited": "已审核", +} + +FOLLOWUP_CONTENT_KEYS = ("f_19iplxz0nds", "content", "description", "remark") +FOLLOWUP_DATE_KEYS = ("f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt") +FOLLOWUP_TYPE_LABELS = { + "tdwfk5h1ohv": "邮件", + "s48pnyhutwc": "电话", + "mwyy8xwk66m": "视频会议", + "m34aocz3ebo": "拜访/请客", + "0lmj78zpdnm": "审厂/来访", + "o7rjlg4guzh": "论坛/大会", + "2csix8r3h0f": "代理跟进情况代填", +} +FOLLOWUP_STATUS_LABELS = { + "2rowk1bc8vc": "审批中", + "3ge6geal2u1": "OK", + "68vqz8raus6": "迟交", + "b5pf5njqgp6": "无效", +} +INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6" + + +@dataclass(frozen=True) +class LabelSpec: + """How to identify and name one related record. + + Every relation the API renders (customer, contact, user, project) resolves to + the same ``{"id": ..., "name": ...}`` shape through this spec, so the same + entity looks identical in every tool response. + """ + + id_field: str = "id" + name_fields: tuple[str, ...] = () + + def projection_fields(self) -> list[str]: + return _unique_strings([self.id_field, *self.name_fields]) + + +def _page_from_offset(limit: int, offset: int) -> int: + bounded_limit = max(1, int(limit)) + bounded_offset = max(0, int(offset)) + return bounded_offset // bounded_limit + 1 + + +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) + except (TypeError, ValueError): + return default + + +def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]: + data = response.get("data") + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(data, dict): + for key in ("data", "rows", "results"): + nested = data.get(key) + if isinstance(nested, list): + return [item for item in nested if isinstance(item, dict)] + return [] + + +def _total(response: Mapping[str, Any], fallback: int) -> int: + meta = response.get("meta") + if isinstance(meta, dict): + for key in ("count", "total", "totalCount"): + value = meta.get(key) + if isinstance(value, int): + return value + data = response.get("data") + if isinstance(data, dict): + for key in ("count", "total", "totalCount"): + value = data.get(key) + if isinstance(value, int): + return value + return fallback + + +def _paginated_response(response: Mapping[str, Any], limit: int, offset: int) -> dict[str, Any]: + rows = _data(response) + total = _total(response, len(rows)) + return _paginated_rows(rows, total, limit, offset) + + +def _paginated_rows(rows: list[dict[str, Any]], total: int, limit: int, offset: int) -> dict[str, Any]: + next_offset = offset + len(rows) + return { + "results": rows, + "total": total, + "returned": len(rows), + "limit": limit, + "offset": offset, + "has_more": bool(rows) and next_offset < total, + "next_offset": next_offset if next_offset < total else None, + } + + +def _pagination_metadata(result: Mapping[str, Any]) -> dict[str, Any]: + return {key: result.get(key) for key in ("total", "returned", "limit", "offset", "has_more", "next_offset")} + + +def _beijing_datetime(value: Any, timezone: str) -> Any: + if not isinstance(value, str) or not value.strip(): + return value + normalized = value.strip() + if len(normalized) == 10: + try: + datetime.strptime(normalized, "%Y-%m-%d") + except ValueError: + return value + return normalized + candidate = normalized[:-1] + "+00:00" if normalized.endswith("Z") else normalized + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + return value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + try: + return parsed.astimezone(ZoneInfo(timezone)).isoformat() + except (ValueError, KeyError): + return parsed.astimezone(ZoneInfo("Asia/Shanghai")).isoformat() + + +def _parse_datetime(value: Any, timezone: str) -> datetime | None: + normalized = _beijing_datetime(value, timezone) + if not isinstance(normalized, str) or len(normalized) == 10: + return None + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + + +def _first_numeric(rows: list[dict[str, Any]], preferred_key: str) -> int: + for row in rows: + value = row.get(preferred_key) + if isinstance(value, (int, float)): + return int(value) + for nested_value in row.values(): + if isinstance(nested_value, (int, float)): + return int(nested_value) + return 0 + + +def _decorate_dimension_counts( + rows: list[dict[str, Any]], + *, + preferred_keys: list[str], + labels: Mapping[Any, str], +) -> list[dict[str, Any]]: + decorated: list[dict[str, Any]] = [] + for row in rows: + value, key = _dimension_value(row, preferred_keys) + item = dict(row) + if value is not None: + label = labels.get(value) or str(value) + # The raw dimension key is dropped once normalized -- keeping it meant + # every bucket carried the same value under an internal field code too. + if key is not None and key not in {"value", "label"}: + item.pop(key, None) + item["value"] = value + item["label"] = label + decorated.append(item) + decorated.sort(key=lambda item: (_is_blank(item.get("value")), -_count_value(item), str(item.get("label") or ""))) + return decorated + + +def _decorate_id_counts( + rows: list[dict[str, Any]], + *, + preferred_keys: list[str], + output_key: str, +) -> list[dict[str, Any]]: + decorated: list[dict[str, Any]] = [] + for row in rows: + value, key = _dimension_value(row, preferred_keys) + item = dict(row) + if value is not None: + if key is not None and key != output_key: + item.pop(key, None) + item[output_key] = value + decorated.append(item) + decorated.sort(key=lambda item: (-_count_value(item), str(item.get(output_key) or ""))) + return decorated + + +def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple[Any | None, str | None]: + for key in preferred_keys: + if key in row: + return row[key], key + for key, value in row.items(): + if key == "count": + continue + if not isinstance(value, (dict, list)): + return value, str(key) + 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) + if value is None or value == "": + continue + if isinstance(value, (dict, list)): + continue + return str(value) + return None + + +def _prune(mapping: Mapping[str, Any]) -> dict[str, Any]: + """Drop keys whose value carries no information. + + An absent key means "empty", never "unknown" -- callers reconciling records + should not have to skip past a screenful of nulls and empty lists. ``id`` is + always kept so a record stays addressable even when nothing else resolved. + """ + pruned: dict[str, Any] = {} + for key, value in mapping.items(): + if key == "id" or not _is_empty(value): + pruned[key] = value + return pruned + + +def _is_empty(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return value.strip() == "" + if isinstance(value, (list, tuple, dict, set)): + return len(value) == 0 + return False + + +def _ref_from_mapping(item: Mapping[str, Any], spec: LabelSpec) -> dict[str, Any]: + """Render one related record as ``{"id": ..., "name": ...}``. + + Deliberately never falls back to the raw id as the *name*: a customer whose + name fields are all empty used to surface as e.g. ``"4249"``, which reads + like a real name. It becomes ``"#4249"`` instead -- unmistakably an id, and + a signal that the source record needs its name filled in. + """ + ident = item.get(spec.id_field) + name = _first_text(item, list(spec.name_fields)) + if name is None and ident is not None: + name = f"{UNKNOWN_NAME_PREFIX}{ident}" + return _prune({"id": ident, "name": name}) + + +def _relation_refs(row: Mapping[str, Any], fields: list[str], spec: LabelSpec) -> list[dict[str, Any]]: + refs: list[dict[str, Any]] = [] + seen: set[Any] = set() + for field in _unique_strings(fields): + for item in _relation_rows(row.get(field)): + ref = _ref_from_mapping(item, spec) + if not ref: + continue + key = ref.get("id", ref.get("name")) + if key in seen: + continue + seen.add(key) + refs.append(ref) + return refs + + +def _ref_names(refs: list[dict[str, Any]]) -> list[str]: + return [str(ref["name"]) for ref in refs if ref.get("name")] + + +def _ref_ids(refs: list[dict[str, Any]]) -> list[Any]: + return [ref["id"] for ref in refs if ref.get("id") is not None] + + +def _fields_with_projections(scalars: list[str], projections: list[str]) -> list[str]: + """Combine scalar field names with dotted relation projections. + + A relation requested bare (``gjjl_client``) next to a dotted projection + (``gjjl_client.id``) makes NocoBase expand the whole related row again, which + is exactly the bloat the projections exist to avoid -- so any scalar that is + also a projection root is dropped. + """ + roots = {path.split(".", 1)[0] for path in projections} + kept = [name for name in _unique_strings(scalars) if name not in roots] + return kept + _unique_strings(projections) + + +def _relation_rows(value: Any) -> list[dict[str, Any]]: + if isinstance(value, Mapping): + nested = value.get("data") + if isinstance(nested, list): + return [dict(item) for item in nested if isinstance(item, Mapping)] + return [dict(value)] + if isinstance(value, list): + return [dict(item) for item in value if isinstance(item, Mapping)] + return [] + + +def _relation_ids(value: Any, id_field: str = "id") -> list[Any]: + return [row.get(id_field) for row in _relation_rows(value) if row.get(id_field) is not None] + + +def _unique_values(values: list[Any]) -> list[Any]: + unique: list[Any] = [] + seen: set[Any] = set() + for value in values: + if value in seen: + continue + seen.add(value) + unique.append(value) + return unique + + +def _unique_strings(values: list[str]) -> list[str]: + unique: list[str] = [] + for value in values: + normalized = str(value or "").strip() + if normalized and normalized not in unique: + unique.append(normalized) + return unique + + +def _pick_user_matches(rows: list[dict[str, Any]], query: str, match_fields: list[str]) -> list[dict[str, Any]]: + normalized = query.casefold() + exact = [ + row for row in rows if any(str(row.get(field) or "").strip().casefold() == normalized for field in match_fields) + ] + if exact: + return exact + fuzzy = [ + row for row in rows if any(normalized in str(row.get(field) or "").strip().casefold() for field in match_fields) + ] + return fuzzy + + +def _user_label(row: dict[str, Any]) -> str: + for key in ("nickname", "username", "email", "f_yh_english_name", "id"): + value = row.get(key) + if value is not None and value != "": + return str(value) + return "" + + +def _format_user_candidate(row: dict[str, Any], id_field: str) -> dict[str, Any]: + candidate: dict[str, Any] = { + "id": row.get(id_field), + "label": _user_label(row), + } + for key in ("username", "nickname", "email", "f_yh_english_name"): + value = row.get(key) + if value is not None and value != "": + candidate[key] = value + return candidate diff --git a/nocobase_crm_mcp/server.py b/nocobase_crm_mcp/server.py index 961e7ec..06b5904 100644 --- a/nocobase_crm_mcp/server.py +++ b/nocobase_crm_mcp/server.py @@ -1,94 +1,51 @@ from __future__ import annotations import argparse -import hmac import os -import subprocess import tempfile -from collections.abc import Callable from pathlib import Path -from typing import Annotated, Any +from typing import Any from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.server import TransportSecuritySettings -from pydantic import Field -from starlette.applications import Starlette -from starlette.routing import Mount, Route from .config import load_config from .crm import CrmService -from .nb_client import NbAuthenticationError, NbClient, NbCommandError +from .nb_client import NbClient from .nocobase_http import NocoBaseHttpClient from .projects import ProjectService +from .server_runtime import ( + DEFAULT_HTTP_HOST, + DEFAULT_HTTP_PATH, + DEFAULT_HTTP_PORT, + DEFAULT_TOKEN_ENV, + ENABLE_UPLOADS_ENV, + BearerTokenAuthMiddleware, + _read_nocobase_environment, + _read_token_from_env, + create_http_app, +) +from .server_tools_core import register_core_tools +from .server_tools_projects import register_project_tools +from .server_tools_records import register_record_tools +from .server_tools_write import register_write_tools +from .tool_errors import _safe_raw_tool_result, _safe_tool_result from .uploads import UploadEndpoint -DEFAULT_HTTP_HOST = "127.0.0.1" -DEFAULT_HTTP_PORT = 10087 -DEFAULT_HTTP_PATH = "/mcp" -DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN" ENABLE_CONFIG_TOOLS_ENV = "NOCOBASE_MCP_ENABLE_CONFIG_TOOLS" -ENABLE_RAW_DATA_ENV = "NOCOBASE_MCP_ENABLE_RAW_DATA" -ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS" -UPLOAD_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL" -UPLOAD_JWT_ENV = "NOCOBASE_MCP_UPLOAD_JWT" -UPLOAD_MAX_BYTES_ENV = "NOCOBASE_MCP_UPLOAD_MAX_BYTES" -API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL" -API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN" -DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 - -def _bearer_token(authorization: str) -> str: - scheme, _, token = authorization.partition(" ") - if scheme.lower() != "bearer" or not token: - return "" - return token.strip() - - -def _authorization_header(scope: dict[str, Any]) -> str: - headers = {key.lower(): value for key, value in scope.get("headers", [])} - return headers.get(b"authorization", b"").decode("latin1") - - -class BearerTokenAuthMiddleware: - def __init__(self, app: Any, token: str) -> None: - if not token: - raise ValueError("HTTP MCP token must not be empty") - self.app = app - self.token = token - - async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: - if scope.get("type") != "http": - await self.app(scope, receive, send) - return - if str(scope.get("method") or "").upper() == "OPTIONS": - await send( - { - "type": "http.response.start", - "status": 204, - "headers": [ - (b"access-control-allow-origin", b"*"), - (b"access-control-allow-methods", b"POST, OPTIONS"), - (b"access-control-allow-headers", b"authorization, content-type, mcp-session-id"), - ], - } - ) - await send({"type": "http.response.body", "body": b""}) - return - token = _bearer_token(_authorization_header(scope)) - if not hmac.compare_digest(token, self.token): - await send( - { - "type": "http.response.start", - "status": 401, - "headers": [ - (b"content-type", b"application/json"), - (b"www-authenticate", b"Bearer"), - ], - } - ) - await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'}) - return - await self.app(scope, receive, send) +__all__ = [ + "BearerTokenAuthMiddleware", + "DEFAULT_HTTP_HOST", + "DEFAULT_HTTP_PATH", + "DEFAULT_HTTP_PORT", + "FastMCP", + "_make_write_services", + "_read_nocobase_environment", + "create_http_app", + "create_server", + "run_http_server", +] def _make_service(config_path: str | Path | None = None) -> CrmService: @@ -120,64 +77,6 @@ def _make_write_services( return CrmService(config, client), ProjectService(config.project, client, http_client), http_client -def _safe_tool_result(operation: Callable[[], dict[str, Any]]) -> dict[str, Any]: - try: - return operation() - except NbAuthenticationError as error: - return _tool_error( - code="nb_auth_failed", - message=str(error), - hint=( - "Ask the local installation Agent to run the secure connect-key flow, then enter your personal " - "NocoBase API Key only in the private local prompt." - ), - retryable=False, - ) - 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 _safe_raw_tool_result(include_raw: bool, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]: - if include_raw and os.environ.get(ENABLE_RAW_DATA_ENV, "").strip() != "1": - return _tool_error( - code="raw_data_disabled", - message="Raw NocoBase rows are disabled for this local MCP installation.", - hint=f"Keep include_raw=false, or set {ENABLE_RAW_DATA_ENV}=1 after approving the data exposure policy.", - retryable=False, - ) - return _safe_tool_result(operation) - - -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, @@ -195,6 +94,7 @@ def create_server( service = crm_service or _make_service(config_path) if not readonly and project_service is None: raise ValueError("project_service is required when readonly is false") + transport_security = None if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None): transport_security = TransportSecuritySettings( @@ -209,486 +109,17 @@ def create_server( transport_security=transport_security, ) - @server.tool() - def crm_health() -> dict[str, Any]: - """Check NocoBase CLI reachability and the configured CRM collection mapping.""" - return _safe_tool_result(service.crm_health) - - @server.tool() - def crm_schema( - include_fields: Annotated[ - bool, - Field(description="When true, try to include NocoBase collection and field metadata."), - ] = False, - ) -> dict[str, Any]: - """Return the configured CRM collection and field mapping for this MCP server.""" - return _safe_tool_result(lambda: service.crm_schema(include_fields=include_fields)) - - @server.tool() - def crm_overview( - date_from: Annotated[ - str | None, - Field( - description="Optional lower bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut." - ), - ] = None, - date_to: Annotated[ - str | None, - Field( - description="Optional upper bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut." - ), - ] = None, - owner: Annotated[ - str | None, - Field(description="Optional user to filter customer ownership and follow-up participation."), - ] = None, - ) -> dict[str, Any]: - """Return CRM totals, common customer distributions, and recent follow-ups.""" - return _safe_tool_result(lambda: service.crm_overview(date_from=date_from, date_to=date_to, owner=owner)) - - @server.tool() - def crm_filter_options( - owner_query: Annotated[ - str | None, - Field(description="Optional owner search text. Returns matching user candidates for owner filters."), - ] = None, - ) -> dict[str, Any]: - """Return available customer filter values and optional owner candidates.""" - 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 _safe_tool_result(lambda: service.resolve_owner(query)) - - @server.tool() - def list_teams( - query: Annotated[str | None, Field(description="Optional team name search.")] = None, - include_members: Annotated[bool, Field(description="Include resolved team members.")] = False, - formal_members_only: Annotated[ - bool, - Field(description="When including members, keep only formal employees."), - ] = True, - limit: Annotated[int, Field(description="Maximum teams to return.")] = 20, - offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, - ) -> dict[str, Any]: - """List configured CRM teams and optionally their formal members.""" - return _safe_tool_result( - lambda: service.list_teams( - query, - include_members=include_members, - formal_members_only=formal_members_only, - limit=limit, - offset=offset, - ) - ) - - @server.tool() - def resolve_team( - query: Annotated[str, Field(description="Team name to resolve.")], - ) -> dict[str, Any]: - """Resolve a team name before using it as a CRM filter.""" - return _safe_tool_result(lambda: service.resolve_team(query)) - - @server.tool() - def get_team( - team_id: Annotated[Any, Field(description="NocoBase team ID.")], - include_members: Annotated[bool, Field(description="Include team members.")] = True, - formal_members_only: Annotated[ - bool, - Field(description="Return only formal employees when members are included."), - ] = True, - ) -> dict[str, Any]: - """Get one CRM team, its managers, and optionally its members.""" - return _safe_tool_result( - lambda: service.get_team( - team_id, - include_members=include_members, - formal_members_only=formal_members_only, - ) - ) - - @server.tool() - def team_crm_overview( - team: Annotated[str, Field(description="Exact or uniquely matching CRM team name.")], - date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None, - date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None, - include_member_breakdown: Annotated[ - bool, - Field(description="Include per-member customer ownership and follow-up contribution."), - ] = True, - ) -> dict[str, Any]: - """Return team totals and member contributions using formal CRM team membership.""" - return _safe_tool_result( - lambda: service.team_crm_overview( - team, - date_from=date_from, - date_to=date_to, - include_member_breakdown=include_member_breakdown, - ) - ) - - 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 _safe_tool_result(service.crm_config_template) - - @server.tool() - def list_customers( - query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None, - status: Annotated[str | None, Field(description="Optional exact customer status.")] = None, - owner: Annotated[ - str | None, Field(description="Optional customer owner name, username, email, or English name.") - ] = None, - team: Annotated[str | None, Field(description="Optional CRM team name.")] = 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, - 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 compact rows and pagination metadata. - - Rows are {id, name, english_name, territorial_name, status, source, - owners: [{id, name}], managers, createdAt, updatedAt}. Empty values are - omitted. A name shown as "#123" means that customer has no name recorded. - """ - return _safe_raw_tool_result( - include_raw, - lambda: service.list_customers( - query=query, - status=status, - owner=owner, - team=team, - source=source, - limit=limit, - offset=offset, - include_raw=include_raw, - ), - ) - - @server.tool() - def get_customer( - customer_id: Annotated[Any, Field(description="Customer primary key value.")], - 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: Annotated[ - bool, - Field(description="When true, include original NocoBase rows under raw. Defaults to false."), - ] = False, - ) -> dict[str, Any]: - """Get one customer with its contacts and recent follow-ups. - - Returns {customer, contacts, recent_followups, timezone}. The customer is - {id, name, english_name, territorial_name, status, source, owners, managers, - address, intro, tags, ...} using readable keys, never NocoBase field codes. - Contacts are {id, name, title} without phone or email -- call list_contacts - for those. Follow-ups use the same row shape as list_followups. Empty values - are omitted, so a missing key means empty rather than unknown. - """ - return _safe_raw_tool_result( - include_raw, - lambda: service.get_customer( - customer_id, - include_contacts=include_contacts, - include_recent_followups=include_recent_followups, - followup_limit=followup_limit, - include_raw=include_raw, - ), - ) - - @server.tool() - def list_contacts( - customer_id: Annotated[ - Any | None, Field(description="Optional customer primary key for relation filtering.") - ] = 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, - offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0, - include_details: Annotated[ - bool, - Field(description="Include contact phone and email. Defaults to true."), - ] = True, - 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 as compact rows, optionally filtered by customer. - - Rows are {id, name, title, phone, email, customers: [{id, name}], - createdAt, updatedAt}. Set include_details=false to omit phone and email. - Empty values are omitted. - """ - return _safe_raw_tool_result( - include_raw, - lambda: service.list_contacts( - customer_id=customer_id, - query=query, - limit=limit, - offset=offset, - include_details=include_details, - include_raw=include_raw, - ), - ) - - @server.tool() - def list_followups( - customer_id: Annotated[ - Any | None, Field(description="Optional customer primary key for relation filtering.") - ] = None, - contact_id: Annotated[ - Any | None, Field(description="Optional contact primary key for relation filtering.") - ] = None, - owner: Annotated[ - str | None, Field(description="Optional follow-up participant name, username, email, or English name.") - ] = None, - team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, - project_id: Annotated[Any | None, Field(description="Optional CRM project ID.")] = None, - followup_type: Annotated[str | None, Field(description="Optional exact follow-up type value.")] = None, - record_status: Annotated[str | None, Field(description="Optional exact follow-up record status value.")] = None, - date_from: Annotated[str | None, Field(description="Optional lower 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, - 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 as compact rows with pagination metadata. - - Rows are {id, followedAt, customers: [{id, name}], contacts, participants, - projects, type, status, location, content}. Content is the full follow-up - text, never truncated. type and status are the display labels; use - crm_filter_options for the codes the followup_type and record_status - filters expect. creator appears only when the submitter is not also a - participant. Empty values are omitted. - """ - return _safe_raw_tool_result( - include_raw, - lambda: service.list_followups( - customer_id=customer_id, - contact_id=contact_id, - owner=owner, - team=team, - project_id=project_id, - followup_type=followup_type, - record_status=record_status, - date_from=date_from, - date_to=date_to, - limit=limit, - offset=offset, - include_raw=include_raw, - ), - ) - - @server.tool() - def list_projects( - customer_id: Annotated[Any | None, Field(description="Optional customer ID.")] = None, - team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, - stage: Annotated[str | None, Field(description="Optional project stage label or configured value.")] = None, - limit: Annotated[int, Field(description="Maximum projects to return.")] = 20, - offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, - include_raw: Annotated[bool, Field(description="Include original NocoBase project rows.")] = False, - ) -> dict[str, Any]: - """List CRM projects linked to customers, teams, and project stages.""" - return _safe_raw_tool_result( - include_raw, - lambda: service.list_projects( - customer_id=customer_id, - team=team, - stage=stage, - limit=limit, - offset=offset, - include_raw=include_raw, - ), - ) - - @server.tool() - def get_project( - project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")], - include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True, - include_followups: Annotated[bool, Field(description="Include related follow-ups.")] = True, - include_updates: Annotated[bool, Field(description="Include project update records.")] = True, - ) -> dict[str, Any]: - """Get a CRM project with its existing NocoBase relations.""" - return _safe_tool_result( - lambda: service.get_project( - project_id, - include_contacts=include_contacts, - include_followups=include_followups, - include_updates=include_updates, - ) - ) - - @server.tool() - def get_project_timeline( - project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")], - date_from: Annotated[str | None, Field(description="Optional lower event date bound.")] = None, - date_to: Annotated[str | None, Field(description="Optional upper event date bound.")] = None, - ) -> dict[str, Any]: - """Return a chronological CRM project timeline from follow-ups and project updates.""" - return _safe_tool_result(lambda: service.get_project_timeline(project_id, date_from=date_from, date_to=date_to)) - - @server.tool() - def list_stale_customers( - team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, - owner: Annotated[str | None, Field(description="Optional customer owner.")] = None, - inactive_days: Annotated[int, Field(description="Days without a valid follow-up.")] = 30, - as_of: Annotated[str | None, Field(description="Reference date in YYYY-MM-DD format.")] = None, - limit: Annotated[int, Field(description="Maximum customers to return.")] = 20, - offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, - ) -> dict[str, Any]: - """List customers whose last valid follow-up is older than the configured threshold.""" - return _safe_tool_result( - lambda: service.list_stale_customers( - team=team, - owner=owner, - inactive_days=inactive_days, - as_of=as_of, - limit=limit, - offset=offset, - ) - ) - - if not readonly: - - @server.tool() - def create_project( - name: Annotated[str, Field(description="Project name. It must not be blank.")], - customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None, - inquiry_date: Annotated[ - str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.") - ] = None, - progress: Annotated[ - str | None, Field(description="Optional configured Chinese project progress label.") - ] = None, - applications: Annotated[str | None, Field(description="Optional project applications.")] = None, - part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None, - period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None, - details: Annotated[str | None, Field(description="Optional project details.")] = None, - attachment_ids: Annotated[ - list[Any] | None, Field(description="Optional uploaded attachment IDs to associate.") - ] = None, - ) -> dict[str, Any]: - """Create one fixed-schema NocoBase project and optionally associate uploaded attachments.""" - assert project_service is not None - return _safe_tool_result( - lambda: project_service.create_project( - name=name, - customer_id=customer_id, - inquiry_date=inquiry_date, - progress=progress, - applications=applications, - part_numbers=part_numbers, - period_usage=period_usage, - details=details, - attachment_ids=attachment_ids, - ) - ) - - @server.tool() - def update_project( - project_id: Annotated[Any, Field(description="NocoBase project ID.")], - name: Annotated[str | None, Field(description="Optional project name.")] = None, - customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None, - inquiry_date: Annotated[ - str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.") - ] = None, - progress: Annotated[ - str | None, Field(description="Optional configured Chinese project progress label.") - ] = None, - applications: Annotated[str | None, Field(description="Optional project applications.")] = None, - part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None, - period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None, - details: Annotated[str | None, Field(description="Optional project details.")] = None, - add_attachment_ids: Annotated[ - list[Any] | None, Field(description="Attachment IDs to add incrementally.") - ] = None, - remove_attachment_ids: Annotated[ - list[Any] | None, Field(description="Attachment IDs to remove incrementally.") - ] = None, - ) -> dict[str, Any]: - """Update fixed project fields and incrementally add or remove uploaded attachments.""" - assert project_service is not None - return _safe_tool_result( - lambda: project_service.update_project( - project_id, - name=name, - customer_id=customer_id, - inquiry_date=inquiry_date, - progress=progress, - applications=applications, - part_numbers=part_numbers, - period_usage=period_usage, - details=details, - add_attachment_ids=add_attachment_ids, - remove_attachment_ids=remove_attachment_ids, - ) - ) - - return server - - -def _read_token_from_env(env_name: str) -> str: - token = os.environ.get(env_name, "").strip() - if not token: - raise RuntimeError(f"{env_name} must be set for HTTP MCP transport") - return token - - -def create_http_app(server: Any, token: str, upload_endpoint: UploadEndpoint | None = None) -> Any: - if upload_endpoint is None: - return BearerTokenAuthMiddleware(server.streamable_http_app(), token) - - async def upload_route(request: Any) -> Any: - return await upload_endpoint(request) - - mcp_app = server.streamable_http_app() - app = Starlette( - routes=[ - Route("/uploads", endpoint=upload_route, methods=["POST"]), - Mount("/", app=mcp_app), - ], - lifespan=mcp_app.router.lifespan_context, + register_core_tools( + server, + service, + _safe_tool_result, + enable_config_tools=os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1", ) - return BearerTokenAuthMiddleware(app, token) - - -def _read_nocobase_environment() -> tuple[str | None, str | None, int]: - canonical_url = os.environ.get(API_BASE_URL_ENV, "").strip().rstrip("/") - canonical_token = os.environ.get(API_TOKEN_ENV, "").strip() - legacy_url = os.environ.get(UPLOAD_API_BASE_URL_ENV, "").strip().rstrip("/") - legacy_token = os.environ.get(UPLOAD_JWT_ENV, "").strip() - - canonical_any = bool(canonical_url or canonical_token) - legacy_any = bool(legacy_url or legacy_token) - if canonical_any and not (canonical_url and canonical_token): - raise RuntimeError(f"{API_BASE_URL_ENV} and {API_TOKEN_ENV} must be set together") - if legacy_any and not (legacy_url and legacy_token): - raise RuntimeError(f"{UPLOAD_API_BASE_URL_ENV} and {UPLOAD_JWT_ENV} must be set together") - if canonical_any and legacy_any and (canonical_url != legacy_url or canonical_token != legacy_token): - raise RuntimeError("Canonical and legacy NocoBase credential environment variables conflict") - if canonical_any: - api_base_url, api_token = canonical_url, canonical_token - elif legacy_any: - api_base_url, api_token = legacy_url, legacy_token - else: - api_base_url, api_token = None, None - - raw_max_bytes = os.environ.get(UPLOAD_MAX_BYTES_ENV, str(DEFAULT_UPLOAD_MAX_BYTES)).strip() - try: - max_bytes = int(raw_max_bytes) - except ValueError as error: - raise RuntimeError(f"{UPLOAD_MAX_BYTES_ENV} must be a positive integer") from error - if max_bytes <= 0: - raise RuntimeError(f"{UPLOAD_MAX_BYTES_ENV} must be a positive integer") - return api_base_url, api_token, max_bytes + register_record_tools(server, service, _safe_tool_result, _safe_raw_tool_result) + register_project_tools(server, service, _safe_tool_result, _safe_raw_tool_result) + if not readonly: + register_write_tools(server, project_service, _safe_tool_result) + return server def run_http_server( diff --git a/nocobase_crm_mcp/server_runtime.py b/nocobase_crm_mcp/server_runtime.py new file mode 100644 index 0000000..eeccb81 --- /dev/null +++ b/nocobase_crm_mcp/server_runtime.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import hmac +import os +from typing import Any + +from starlette.applications import Starlette +from starlette.routing import Mount, Route + +from .uploads import UploadEndpoint + +DEFAULT_HTTP_HOST = "127.0.0.1" +DEFAULT_HTTP_PORT = 10087 +DEFAULT_HTTP_PATH = "/mcp" +DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN" +ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS" +UPLOAD_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL" +UPLOAD_JWT_ENV = "NOCOBASE_MCP_UPLOAD_JWT" +UPLOAD_MAX_BYTES_ENV = "NOCOBASE_MCP_UPLOAD_MAX_BYTES" +API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL" +API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN" +DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 + + +def _bearer_token(authorization: str) -> str: + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + return "" + return token.strip() + + +def _authorization_header(scope: dict[str, Any]) -> str: + headers = {key.lower(): value for key, value in scope.get("headers", [])} + return headers.get(b"authorization", b"").decode("latin1") + + +class BearerTokenAuthMiddleware: + def __init__(self, app: Any, token: str) -> None: + if not token: + raise ValueError("HTTP MCP token must not be empty") + self.app = app + self.token = token + + async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + if str(scope.get("method") or "").upper() == "OPTIONS": + await send( + { + "type": "http.response.start", + "status": 204, + "headers": [ + (b"access-control-allow-origin", b"*"), + (b"access-control-allow-methods", b"POST, OPTIONS"), + (b"access-control-allow-headers", b"authorization, content-type, mcp-session-id"), + ], + } + ) + await send({"type": "http.response.body", "body": b""}) + return + token = _bearer_token(_authorization_header(scope)) + if not hmac.compare_digest(token, self.token): + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [(b"content-type", b"application/json"), (b"www-authenticate", b"Bearer")], + } + ) + await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'}) + return + await self.app(scope, receive, send) + + +def read_token_from_env(env_name: str) -> str: + token = os.environ.get(env_name, "").strip() + if not token: + raise RuntimeError(f"{env_name} must be set for HTTP MCP transport") + return token + + +def create_http_app(server: Any, token: str, upload_endpoint: UploadEndpoint | None = None) -> Any: + if upload_endpoint is None: + return BearerTokenAuthMiddleware(server.streamable_http_app(), token) + + async def upload_route(request: Any) -> Any: + return await upload_endpoint(request) + + mcp_app = server.streamable_http_app() + app = Starlette( + routes=[Route("/uploads", endpoint=upload_route, methods=["POST"]), Mount("/", app=mcp_app)], + lifespan=mcp_app.router.lifespan_context, + ) + return BearerTokenAuthMiddleware(app, token) + + +def read_nocobase_environment() -> tuple[str | None, str | None, int]: + canonical_url = os.environ.get(API_BASE_URL_ENV, "").strip().rstrip("/") + canonical_token = os.environ.get(API_TOKEN_ENV, "").strip() + legacy_url = os.environ.get(UPLOAD_API_BASE_URL_ENV, "").strip().rstrip("/") + legacy_token = os.environ.get(UPLOAD_JWT_ENV, "").strip() + + canonical_any = bool(canonical_url or canonical_token) + legacy_any = bool(legacy_url or legacy_token) + if canonical_any and not (canonical_url and canonical_token): + raise RuntimeError(f"{API_BASE_URL_ENV} and {API_TOKEN_ENV} must be set together") + if legacy_any and not (legacy_url and legacy_token): + raise RuntimeError(f"{UPLOAD_API_BASE_URL_ENV} and {UPLOAD_JWT_ENV} must be set together") + if canonical_any and legacy_any and (canonical_url != legacy_url or canonical_token != legacy_token): + raise RuntimeError("Canonical and legacy NocoBase credential environment variables conflict") + if canonical_any: + api_base_url, api_token = canonical_url, canonical_token + elif legacy_any: + api_base_url, api_token = legacy_url, legacy_token + else: + api_base_url, api_token = None, None + + raw_max_bytes = os.environ.get(UPLOAD_MAX_BYTES_ENV, str(DEFAULT_UPLOAD_MAX_BYTES)).strip() + try: + max_bytes = int(raw_max_bytes) + except ValueError as error: + raise RuntimeError(f"{UPLOAD_MAX_BYTES_ENV} must be a positive integer") from error + if max_bytes <= 0: + raise RuntimeError(f"{UPLOAD_MAX_BYTES_ENV} must be a positive integer") + return api_base_url, api_token, max_bytes + + +_read_token_from_env = read_token_from_env +_read_nocobase_environment = read_nocobase_environment + +__all__ = [ + "API_BASE_URL_ENV", + "API_TOKEN_ENV", + "BearerTokenAuthMiddleware", + "DEFAULT_HTTP_HOST", + "DEFAULT_HTTP_PATH", + "DEFAULT_HTTP_PORT", + "DEFAULT_TOKEN_ENV", + "DEFAULT_UPLOAD_MAX_BYTES", + "ENABLE_UPLOADS_ENV", + "UPLOAD_API_BASE_URL_ENV", + "UPLOAD_JWT_ENV", + "UPLOAD_MAX_BYTES_ENV", + "create_http_app", + "read_nocobase_environment", + "read_token_from_env", + "_read_nocobase_environment", + "_read_token_from_env", +] diff --git a/nocobase_crm_mcp/server_tools_core.py b/nocobase_crm_mcp/server_tools_core.py new file mode 100644 index 0000000..b871a6c --- /dev/null +++ b/nocobase_crm_mcp/server_tools_core.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated, Any + +from pydantic import Field + +SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]] +SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]] + + +def register_core_tools(server: Any, service: Any, safe_result: SafeResult, *, enable_config_tools: bool) -> None: + @server.tool() + def crm_health() -> dict[str, Any]: + """Check NocoBase CLI reachability and the configured CRM collection mapping.""" + return safe_result(service.crm_health) + + @server.tool() + def crm_schema( + include_fields: Annotated[ + bool, + Field(description="When true, try to include NocoBase collection and field metadata."), + ] = False, + ) -> dict[str, Any]: + """Return the configured CRM collection and field mapping for this MCP server.""" + return safe_result(lambda: service.crm_schema(include_fields=include_fields)) + + @server.tool() + def crm_overview( + date_from: Annotated[ + str | None, + Field( + description="Optional lower bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut." + ), + ] = None, + date_to: Annotated[ + str | None, + Field( + description="Optional upper bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut." + ), + ] = None, + owner: Annotated[ + str | None, + Field(description="Optional user to filter customer ownership and follow-up participation."), + ] = None, + ) -> dict[str, Any]: + """Return CRM totals, common customer distributions, and recent follow-ups.""" + return safe_result(lambda: service.crm_overview(date_from=date_from, date_to=date_to, owner=owner)) + + @server.tool() + def crm_filter_options( + owner_query: Annotated[ + str | None, + Field(description="Optional owner search text. Returns matching user candidates for owner filters."), + ] = None, + ) -> dict[str, Any]: + """Return available customer filter values and optional owner candidates.""" + return safe_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 safe_result(lambda: service.resolve_owner(query)) + + @server.tool() + def list_teams( + query: Annotated[str | None, Field(description="Optional team name search.")] = None, + include_members: Annotated[bool, Field(description="Include resolved team members.")] = False, + formal_members_only: Annotated[ + bool, + Field(description="When including members, keep only formal employees."), + ] = True, + limit: Annotated[int, Field(description="Maximum teams to return.")] = 20, + offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, + ) -> dict[str, Any]: + """List configured CRM teams and optionally their formal members.""" + return safe_result( + lambda: service.list_teams( + query, + include_members=include_members, + formal_members_only=formal_members_only, + limit=limit, + offset=offset, + ) + ) + + @server.tool() + def resolve_team( + query: Annotated[str, Field(description="Team name to resolve.")], + ) -> dict[str, Any]: + """Resolve a team name before using it as a CRM filter.""" + return safe_result(lambda: service.resolve_team(query)) + + @server.tool() + def get_team( + team_id: Annotated[Any, Field(description="NocoBase team ID.")], + include_members: Annotated[bool, Field(description="Include team members.")] = True, + formal_members_only: Annotated[ + bool, + Field(description="Return only formal employees when members are included."), + ] = True, + ) -> dict[str, Any]: + """Get one CRM team, its managers, and optionally its members.""" + return safe_result( + lambda: service.get_team( + team_id, + include_members=include_members, + formal_members_only=formal_members_only, + ) + ) + + @server.tool() + def team_crm_overview( + team: Annotated[str, Field(description="Exact or uniquely matching CRM team name.")], + date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None, + date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None, + include_member_breakdown: Annotated[ + bool, + Field(description="Include per-member customer ownership and follow-up contribution."), + ] = True, + ) -> dict[str, Any]: + """Return team totals and member contributions using formal CRM team membership.""" + return safe_result( + lambda: service.team_crm_overview( + team, + date_from=date_from, + date_to=date_to, + include_member_breakdown=include_member_breakdown, + ) + ) + + if enable_config_tools: + + @server.tool() + def crm_config_template() -> dict[str, Any]: + """Infer a config.json template from collection field metadata without reading business records.""" + return safe_result(service.crm_config_template) diff --git a/nocobase_crm_mcp/server_tools_projects.py b/nocobase_crm_mcp/server_tools_projects.py new file mode 100644 index 0000000..8dbbdec --- /dev/null +++ b/nocobase_crm_mcp/server_tools_projects.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated, Any + +from pydantic import Field + +SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]] +SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]] + + +def register_project_tools(server: Any, service: Any, safe_result: SafeResult, safe_raw_result: SafeRawResult) -> None: + @server.tool() + def list_projects( + customer_id: Annotated[Any | None, Field(description="Optional customer ID.")] = None, + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, + stage: Annotated[str | None, Field(description="Optional project stage label or configured value.")] = None, + limit: Annotated[int, Field(description="Maximum projects to return.")] = 20, + offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, + include_raw: Annotated[bool, Field(description="Include original NocoBase project rows.")] = False, + ) -> dict[str, Any]: + """List CRM projects linked to customers, teams, and project stages.""" + return safe_raw_result( + include_raw, + lambda: service.list_projects( + customer_id=customer_id, + team=team, + stage=stage, + limit=limit, + offset=offset, + include_raw=include_raw, + ), + ) + + @server.tool() + def get_project( + project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")], + include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True, + include_followups: Annotated[bool, Field(description="Include related follow-ups.")] = True, + include_updates: Annotated[bool, Field(description="Include project update records.")] = True, + ) -> dict[str, Any]: + """Get a CRM project with its existing NocoBase relations.""" + return safe_result( + lambda: service.get_project( + project_id, + include_contacts=include_contacts, + include_followups=include_followups, + include_updates=include_updates, + ) + ) + + @server.tool() + def get_project_timeline( + project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")], + date_from: Annotated[str | None, Field(description="Optional lower event date bound.")] = None, + date_to: Annotated[str | None, Field(description="Optional upper event date bound.")] = None, + ) -> dict[str, Any]: + """Return a chronological CRM project timeline from follow-ups and project updates.""" + return safe_result(lambda: service.get_project_timeline(project_id, date_from=date_from, date_to=date_to)) + + @server.tool() + def list_stale_customers( + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, + owner: Annotated[str | None, Field(description="Optional customer owner.")] = None, + inactive_days: Annotated[int, Field(description="Days without a valid follow-up.")] = 30, + as_of: Annotated[str | None, Field(description="Reference date in YYYY-MM-DD format.")] = None, + limit: Annotated[int, Field(description="Maximum customers to return.")] = 20, + offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, + ) -> dict[str, Any]: + """List customers whose last valid follow-up is older than the configured threshold.""" + return safe_result( + lambda: service.list_stale_customers( + team=team, + owner=owner, + inactive_days=inactive_days, + as_of=as_of, + limit=limit, + offset=offset, + ) + ) diff --git a/nocobase_crm_mcp/server_tools_records.py b/nocobase_crm_mcp/server_tools_records.py new file mode 100644 index 0000000..6792c98 --- /dev/null +++ b/nocobase_crm_mcp/server_tools_records.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated, Any + +from pydantic import Field + +SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]] +SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]] + + +def register_record_tools(server: Any, service: Any, safe_result: SafeResult, safe_raw_result: SafeRawResult) -> None: + @server.tool() + def list_customers( + query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None, + status: Annotated[str | None, Field(description="Optional exact customer status.")] = None, + owner: Annotated[ + str | None, Field(description="Optional customer owner name, username, email, or English name.") + ] = None, + team: Annotated[str | None, Field(description="Optional CRM team name.")] = 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, + 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 compact rows and pagination metadata. + + Rows are {id, name, english_name, territorial_name, status, source, + owners: [{id, name}], managers, createdAt, updatedAt}. Empty values are + omitted. A name shown as "#123" means that customer has no name recorded. + """ + return safe_raw_result( + include_raw, + lambda: service.list_customers( + query=query, + status=status, + owner=owner, + team=team, + source=source, + limit=limit, + offset=offset, + include_raw=include_raw, + ), + ) + + @server.tool() + def get_customer( + customer_id: Annotated[Any, Field(description="Customer primary key value.")], + 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: Annotated[ + bool, + Field(description="When true, include original NocoBase rows under raw. Defaults to false."), + ] = False, + ) -> dict[str, Any]: + """Get one customer with its contacts and recent follow-ups. + + Returns {customer, contacts, recent_followups, timezone}. The customer is + {id, name, english_name, territorial_name, status, source, owners, managers, + address, intro, tags, ...} using readable keys, never NocoBase field codes. + Contacts are {id, name, title} without phone or email -- call list_contacts + for those. Follow-ups use the same row shape as list_followups. Empty values + are omitted, so a missing key means empty rather than unknown. + """ + return safe_raw_result( + include_raw, + lambda: service.get_customer( + customer_id, + include_contacts=include_contacts, + include_recent_followups=include_recent_followups, + followup_limit=followup_limit, + include_raw=include_raw, + ), + ) + + @server.tool() + def list_contacts( + customer_id: Annotated[ + Any | None, Field(description="Optional customer primary key for relation filtering.") + ] = 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, + offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0, + include_details: Annotated[ + bool, + Field(description="Include contact phone and email. Defaults to true."), + ] = True, + 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 as compact rows, optionally filtered by customer. + + Rows are {id, name, title, phone, email, customers: [{id, name}], + createdAt, updatedAt}. Set include_details=false to omit phone and email. + Empty values are omitted. + """ + return safe_raw_result( + include_raw, + lambda: service.list_contacts( + customer_id=customer_id, + query=query, + limit=limit, + offset=offset, + include_details=include_details, + include_raw=include_raw, + ), + ) + + @server.tool() + def list_followups( + customer_id: Annotated[ + Any | None, Field(description="Optional customer primary key for relation filtering.") + ] = None, + contact_id: Annotated[ + Any | None, Field(description="Optional contact primary key for relation filtering.") + ] = None, + owner: Annotated[ + str | None, Field(description="Optional follow-up participant name, username, email, or English name.") + ] = None, + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, + project_id: Annotated[Any | None, Field(description="Optional CRM project ID.")] = None, + followup_type: Annotated[str | None, Field(description="Optional exact follow-up type value.")] = None, + record_status: Annotated[str | None, Field(description="Optional exact follow-up record status value.")] = None, + date_from: Annotated[str | None, Field(description="Optional lower 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, + 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 as compact rows with pagination metadata. + + Rows are {id, followedAt, customers: [{id, name}], contacts, participants, + projects, type, status, location, content}. Content is the full follow-up + text, never truncated. type and status are the display labels; use + crm_filter_options for the codes the followup_type and record_status + filters expect. creator appears only when the submitter is not also a + participant. Empty values are omitted. + """ + return safe_raw_result( + include_raw, + lambda: service.list_followups( + customer_id=customer_id, + contact_id=contact_id, + owner=owner, + team=team, + project_id=project_id, + followup_type=followup_type, + record_status=record_status, + date_from=date_from, + date_to=date_to, + limit=limit, + offset=offset, + include_raw=include_raw, + ), + ) diff --git a/nocobase_crm_mcp/server_tools_write.py b/nocobase_crm_mcp/server_tools_write.py new file mode 100644 index 0000000..919c733 --- /dev/null +++ b/nocobase_crm_mcp/server_tools_write.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated, Any + +from pydantic import Field + +SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]] +SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]] + + +def register_write_tools(server: Any, project_service: Any, safe_result: SafeResult) -> None: + @server.tool() + def create_project( + name: Annotated[str, Field(description="Project name. It must not be blank.")], + customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None, + inquiry_date: Annotated[ + str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.") + ] = None, + progress: Annotated[ + str | None, Field(description="Optional configured Chinese project progress label.") + ] = None, + applications: Annotated[str | None, Field(description="Optional project applications.")] = None, + part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None, + period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None, + details: Annotated[str | None, Field(description="Optional project details.")] = None, + attachment_ids: Annotated[ + list[Any] | None, Field(description="Optional uploaded attachment IDs to associate.") + ] = None, + ) -> dict[str, Any]: + """Create one fixed-schema NocoBase project and optionally associate uploaded attachments.""" + assert project_service is not None + return safe_result( + lambda: project_service.create_project( + name=name, + customer_id=customer_id, + inquiry_date=inquiry_date, + progress=progress, + applications=applications, + part_numbers=part_numbers, + period_usage=period_usage, + details=details, + attachment_ids=attachment_ids, + ) + ) + + @server.tool() + def update_project( + project_id: Annotated[Any, Field(description="NocoBase project ID.")], + name: Annotated[str | None, Field(description="Optional project name.")] = None, + customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None, + inquiry_date: Annotated[ + str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.") + ] = None, + progress: Annotated[ + str | None, Field(description="Optional configured Chinese project progress label.") + ] = None, + applications: Annotated[str | None, Field(description="Optional project applications.")] = None, + part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None, + period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None, + details: Annotated[str | None, Field(description="Optional project details.")] = None, + add_attachment_ids: Annotated[ + list[Any] | None, Field(description="Attachment IDs to add incrementally.") + ] = None, + remove_attachment_ids: Annotated[ + list[Any] | None, Field(description="Attachment IDs to remove incrementally.") + ] = None, + ) -> dict[str, Any]: + """Update fixed project fields and incrementally add or remove uploaded attachments.""" + assert project_service is not None + return safe_result( + lambda: project_service.update_project( + project_id, + name=name, + customer_id=customer_id, + inquiry_date=inquiry_date, + progress=progress, + applications=applications, + part_numbers=part_numbers, + period_usage=period_usage, + details=details, + add_attachment_ids=add_attachment_ids, + remove_attachment_ids=remove_attachment_ids, + ) + ) diff --git a/nocobase_crm_mcp/tool_errors.py b/nocobase_crm_mcp/tool_errors.py new file mode 100644 index 0000000..8f70def --- /dev/null +++ b/nocobase_crm_mcp/tool_errors.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import os +import subprocess +from collections.abc import Callable +from typing import Any + +from .nb_client import NbAuthenticationError, NbCommandError + +ENABLE_RAW_DATA_ENV = "NOCOBASE_MCP_ENABLE_RAW_DATA" + + +def safe_tool_result(operation: Callable[[], dict[str, Any]]) -> dict[str, Any]: + try: + return operation() + except NbAuthenticationError as error: + return tool_error( + code="nb_auth_failed", + message=str(error), + hint=( + "Ask the local installation Agent to run the secure connect-key flow, then enter your personal " + "NocoBase API Key only in the private local prompt." + ), + retryable=False, + ) + 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 safe_raw_tool_result(include_raw: bool, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]: + if include_raw and os.environ.get(ENABLE_RAW_DATA_ENV, "").strip() != "1": + return tool_error( + code="raw_data_disabled", + message="Raw NocoBase rows are disabled for this local MCP installation.", + hint=f"Keep include_raw=false, or set {ENABLE_RAW_DATA_ENV}=1 after approving the data exposure policy.", + retryable=False, + ) + return safe_tool_result(operation) + + +def tool_error(*, code: str, message: str, hint: str, retryable: bool) -> dict[str, Any]: + return {"error": {"code": code, "message": message, "hint": hint, "retryable": retryable}} + + +# Compatibility aliases for internal imports and older tests. +_safe_tool_result = safe_tool_result +_safe_raw_tool_result = safe_raw_tool_result +_tool_error = tool_error + +__all__ = [ + "ENABLE_RAW_DATA_ENV", + "safe_tool_result", + "safe_raw_tool_result", + "tool_error", + "_safe_tool_result", + "_safe_raw_tool_result", + "_tool_error", +] diff --git a/scripts/agent_install.py b/scripts/agent_install.py index aefccff..88db4c7 100644 --- a/scripts/agent_install.py +++ b/scripts/agent_install.py @@ -1,589 +1,49 @@ -#!/usr/bin/env python3 -"""Deterministic helpers for the agent-guided local installation flow. - -This script deliberately does not clone the repository, install global tools, -authenticate to NocoBase, or edit an AI client's configuration. Those actions -need environment-specific judgment or direct user participation and are -governed by INSTALL.md. -""" - from __future__ import annotations -import argparse -import json -import os -import re -import shutil -import subprocess -import sys -import tempfile -from collections.abc import Callable, Sequence -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any -from urllib.parse import urlsplit, urlunsplit +from agent_installer.auth import ( + connect_with_personal_key, + normalize_api_base_url, +) +from agent_installer.cli import build_parser, main +from agent_installer.common import ( + DEFAULT_ENV_NAME, + ENV_NAME_PATTERN, + MIN_PYTHON, + REQUIRED_PROJECT_FILES, + SERVER_NAME, + Check, + InstallerError, + Runner, + Which, +) +from agent_installer.diagnostics import doctor_project +from agent_installer.project import ( + inspect_project, + prepare_project, + render_client_config, + set_config_env, +) -MIN_PYTHON = (3, 12) -DEFAULT_ENV_NAME = "nb-mcp" -SERVER_NAME = "nocobase-crm" -REQUIRED_PROJECT_FILES = ("pyproject.toml", "uv.lock", "config.example.json") -ENV_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") -ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") - -Which = Callable[[str], str | None] -Runner = Callable[..., subprocess.CompletedProcess[str]] - - -class InstallerError(RuntimeError): - """Raised when a deterministic installation step cannot finish safely.""" - - -@dataclass(frozen=True) -class Check: - name: str - ok: bool - required: bool - detail: str - - -def _project_root(path: str | Path | None) -> Path: - candidate = Path(path) if path is not None else Path(__file__).resolve().parent.parent - return candidate.expanduser().resolve() - - -def inspect_project( - project_root: str | Path | None = None, - *, - which: Which = shutil.which, - python_version: tuple[int, int, int] | None = None, -) -> dict[str, Any]: - root = _project_root(project_root) - version = python_version or (sys.version_info.major, sys.version_info.minor, sys.version_info.micro) - commands = {name: which(name) for name in ("git", "uv", "nb")} - missing_files = [name for name in REQUIRED_PROJECT_FILES if not (root / name).is_file()] - checks = [ - Check( - "python", - version[:2] >= MIN_PYTHON, - True, - f"Python {version[0]}.{version[1]}.{version[2]} (requires >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]})", - ), - Check("git", bool(commands["git"]), True, commands["git"] or "git was not found on PATH"), - Check("uv", bool(commands["uv"]), True, commands["uv"] or "uv was not found on PATH"), - Check("nb", bool(commands["nb"]), True, commands["nb"] or "nb was not found on PATH"), - Check( - "project_files", - not missing_files, - True, - "required project files are present" if not missing_files else f"missing: {', '.join(missing_files)}", - ), - Check( - "config", - (root / "config.json").is_file(), - False, - "config.json is present" if (root / "config.json").is_file() else "config.json will be created by prepare", - ), - ] - return { - "ok": all(check.ok for check in checks if check.required), - "projectRoot": str(root), - "checks": [asdict(check) for check in checks], - } - - -def _require_prepare_prerequisites( - root: Path, - *, - which: Which, - python_version: tuple[int, int, int], -) -> str: - inspection = inspect_project(root, which=which, python_version=python_version) - required = {check["name"]: check for check in inspection["checks"] if check["required"]} - blocking = [required[name]["detail"] for name in ("python", "uv", "project_files") if not required[name]["ok"]] - if blocking: - raise InstallerError("; ".join(blocking)) - uv_bin = which("uv") - if not uv_bin: - raise InstallerError("uv was not found on PATH") - return os.path.abspath(uv_bin) - - -def _load_json_object(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise InstallerError(f"Could not read valid JSON from {path}: {error}") from error - if not isinstance(value, dict): - raise InstallerError(f"Expected a JSON object in {path}") - return value - - -def prepare_project( - project_root: str | Path | None = None, - *, - runner: Runner = subprocess.run, - which: Which = shutil.which, - python_version: tuple[int, int, int] | None = None, -) -> dict[str, Any]: - root = _project_root(project_root) - version = python_version or (sys.version_info.major, sys.version_info.minor, sys.version_info.micro) - uv_bin = _require_prepare_prerequisites(root, which=which, python_version=version) - example_path = root / "config.example.json" - config_path = root / "config.json" - _load_json_object(example_path) - - created_config = False - if config_path.exists(): - _load_json_object(config_path) - else: - shutil.copy2(example_path, config_path) - created_config = True - - command = [uv_bin, "sync", "--locked", "--python", f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"] - try: - result = runner(command, cwd=str(root), capture_output=True, text=True, check=False) - except OSError as error: - raise InstallerError(f"Could not execute uv sync: {error}") from error - if result.returncode != 0: - raise InstallerError( - f"uv sync --locked failed with exit code {result.returncode}; rerun it directly to inspect package-manager output" - ) - - return { - "ok": True, - "projectRoot": str(root), - "configPath": str(config_path), - "configCreated": created_config, - "dependencies": "locked", - } - - -def set_config_env(project_root: str | Path | None, env_name: str) -> dict[str, Any]: - if not ENV_NAME_PATTERN.fullmatch(env_name): - raise InstallerError( - "Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash" - ) - root = _project_root(project_root) - config_path = root / "config.json" - config = _load_json_object(config_path) - previous = config.get("env") - if previous == env_name: - return { - "ok": True, - "changed": False, - "configPath": str(config_path), - "env": env_name, - "backupPath": None, - } - - backup_path = root / "config.json.pre-agent-install.bak" - if not backup_path.exists(): - shutil.copy2(config_path, backup_path) - config["env"] = env_name - - fd, temporary_name = tempfile.mkstemp(prefix=".config.json.", dir=root) - temporary_path = Path(temporary_name) - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(config, handle, ensure_ascii=False, indent=2) - handle.write("\n") - os.replace(temporary_path, config_path) - finally: - if temporary_path.exists(): - temporary_path.unlink() - - return { - "ok": True, - "changed": True, - "configPath": str(config_path), - "env": env_name, - "previousEnv": previous, - "backupPath": str(backup_path), - } - - -def normalize_api_base_url(value: str) -> str: - raw = value.strip() - if not raw: - raise InstallerError("NocoBase API base URL is required") - parsed = urlsplit(raw) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise InstallerError("NocoBase URL must be an absolute http:// or https:// URL") - if parsed.username or parsed.password or parsed.query or parsed.fragment: - raise InstallerError("NocoBase URL must not contain credentials, a query string, or a fragment") - hostname = parsed.hostname.lower() - is_local = hostname == "localhost" or hostname == "::1" or hostname.startswith("127.") - if parsed.scheme != "https" and not is_local: - raise InstallerError("A remote NocoBase API Key may only be sent to an https:// endpoint") - path = parsed.path.rstrip("/") - if not path.endswith("/api"): - path = f"{path}/api" if path else "/api" - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) - - -def _parse_cli_json(output: str) -> dict[str, Any]: - decoder = json.JSONDecoder() - candidates: list[tuple[int, int, dict[str, Any]]] = [] - for start, character in enumerate(output): - if character != "{": - continue - try: - value, end = decoder.raw_decode(output, start) - except json.JSONDecodeError: - continue - if isinstance(value, dict): - candidates.append((start, end, value)) - outer_candidates = [ - candidate - for candidate in candidates - if not any( - other_start <= candidate[0] - and other_end >= candidate[1] - and (other_start, other_end) != (candidate[0], candidate[1]) - for other_start, other_end, _value in candidates - ) - ] - if len(outer_candidates) != 1: - raise InstallerError("nb env info did not return one JSON object") - return outer_candidates[0][2] - - -def _nb_command_environment(nb_bin: str) -> dict[str, str]: - environment = os.environ.copy() - environment["NB_CLI_LOG_DISABLED"] = "1" - environment["NB_SKIP_STARTUP_UPDATE"] = "1" - nb_path = Path(nb_bin) - if nb_path.is_absolute(): - nb_dir = str(nb_path.parent) - existing_path = environment.get("PATH", "") - parts = [part for part in existing_path.split(os.pathsep) if part] - if nb_dir not in parts: - environment["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "") - return environment - - -def _read_nb_environment( - root: Path, - env_name: str, - *, - nb_bin: str, - runner: Runner, -) -> dict[str, Any] | None: - command = [nb_bin, "env", "info", env_name, "--json"] - try: - result = runner( - command, - cwd=str(root), - env=_nb_command_environment(nb_bin), - capture_output=True, - text=True, - check=False, - ) - except OSError as error: - raise InstallerError(f"Could not execute nb env info: {error}") from error - if result.returncode == 0: - return _parse_cli_json(result.stdout) - diagnostic = ANSI_ESCAPE_PATTERN.sub("", f"{result.stdout}\n{result.stderr}") - if "is not configured" in diagnostic: - return None - raise InstallerError( - f"nb env info failed with exit code {result.returncode}; inspect the nb CLI diagnostic locally" - ) - - -def _api_details(payload: dict[str, Any]) -> tuple[str | None, str | None]: - api = payload.get("api") - auth = api.get("auth") if isinstance(api, dict) else None - api_base_url = api.get("apiBaseUrl") if isinstance(api, dict) else None - auth_type = auth.get("type") if isinstance(auth, dict) else None - normalized_url = api_base_url.strip().rstrip("/") if isinstance(api_base_url, str) else None - normalized_type = auth_type.strip() if isinstance(auth_type, str) else None - return normalized_url, normalized_type - - -def _suggest_env_name(api_base_url: str) -> str: - hostname = urlsplit(api_base_url).hostname or "server" - suffix = re.sub(r"[^a-z0-9]+", "-", hostname.lower()).strip("-")[:32] or "server" - return f"{DEFAULT_ENV_NAME}-{suffix}" - - -def connect_with_personal_key( - project_root: str | Path | None, - api_base_url: str, - *, - env_name: str = DEFAULT_ENV_NAME, - runner: Runner = subprocess.run, - which: Which = shutil.which, - stdin_isatty: Callable[[], bool] = sys.stdin.isatty, -) -> dict[str, Any]: - """Bind a personal API Key through nb's private TTY prompt. - - The key is never accepted by this function, placed in argv, inherited from - the process environment, captured, printed, or returned. The child nb CLI - reads it directly from the user-controlled terminal. - """ - - if not ENV_NAME_PATTERN.fullmatch(env_name): - raise InstallerError( - "Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash" - ) - if not stdin_isatty(): - raise InstallerError( - "Secure API Key entry requires a user-controlled local terminal. " - "Do not paste the Key into Agent chat or pass it with --access-token." - ) - root = _project_root(project_root) - normalized_url = normalize_api_base_url(api_base_url) - discovered_nb = which("nb") - if not discovered_nb: - raise InstallerError("nb was not found on PATH") - nb_bin = os.path.abspath(discovered_nb) - existing = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner) - - if existing is None: - interactive_command = [ - nb_bin, - "env", - "add", - env_name, - "--api-base-url", - normalized_url, - "--auth-type", - "token", - ] - action = "created" - else: - existing_url, _auth_type = _api_details(existing) - if existing_url != normalized_url.rstrip("/"): - suggestion = _suggest_env_name(normalized_url) - raise InstallerError( - f"Environment {env_name!r} already targets {existing_url or 'an unconfigured endpoint'!r}; " - f"use a new name such as {suggestion!r} instead of overwriting it" - ) - interactive_command = [nb_bin, "env", "auth", env_name, "--auth-type", "token"] - action = "reauthenticated" - - try: - result = runner( - interactive_command, - cwd=str(root), - env=_nb_command_environment(nb_bin), - text=True, - check=False, - ) - except OSError as error: - raise InstallerError(f"Could not execute the interactive nb Key prompt: {error}") from error - if result.returncode != 0: - raise InstallerError( - f"Interactive API Key authentication failed with exit code {result.returncode}; " - "revoke the exposed or invalid Key if necessary and retry through the installer" - ) - - verified = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner) - if verified is None: - raise InstallerError("nb reported success but the saved environment could not be read back") - verified_url, auth_type = _api_details(verified) - if verified_url != normalized_url.rstrip("/") or auth_type != "token": - raise InstallerError("The saved nb environment did not verify as token authentication for the requested URL") - - list_result = runner( - [nb_bin, "env", "list"], - cwd=str(root), - env=_nb_command_environment(nb_bin), - capture_output=True, - text=True, - check=False, - ) - if list_result.returncode != 0: - raise InstallerError("API Key was saved, but nb env list readback failed") - config_result = set_config_env(root, env_name) - return { - "ok": True, - "action": action, - "env": env_name, - "apiBaseUrl": normalized_url, - "authType": "token", - "keyExposure": "private-tty-only", - "configChanged": config_result["changed"], - "configBackupPath": config_result["backupPath"], - } - - -def render_client_config( - project_root: str | Path | None = None, - *, - enable_writes: bool = False, - which: Which = shutil.which, -) -> dict[str, Any]: - root = _project_root(project_root) - uv_bin = which("uv") or "uv" - nb_bin = which("nb") or "nb" - if os.path.isabs(uv_bin): - uv_bin = os.path.abspath(uv_bin) - if os.path.isabs(nb_bin): - nb_bin = os.path.abspath(nb_bin) - args = [ - "--directory", - str(root), - "run", - "--no-sync", - "nocobase-crm-mcp", - "--config", - str(root / "config.json"), - ] - if enable_writes: - args.append("--enable-writes") - return { - "mcpServers": { - SERVER_NAME: { - "command": uv_bin, - "args": args, - "env": {"NOCOBASE_MCP_NB_BIN": nb_bin}, - } - } - } - - -def doctor_project( - project_root: str | Path | None = None, - *, - runner: Runner = subprocess.run, - which: Which = shutil.which, - python_version: tuple[int, int, int] | None = None, -) -> dict[str, Any]: - root = _project_root(project_root) - inspection = inspect_project(root, which=which, python_version=python_version) - checks = [Check(**item) for item in inspection["checks"]] - - config_path = root / "config.json" - if config_path.is_file(): - try: - config = _load_json_object(config_path) - env_name = config.get("env") - config_ok = isinstance(env_name, str) and bool(env_name.strip()) - detail = f"config env is {env_name!r}" if config_ok else "config.json has no non-empty env" - except InstallerError as error: - config_ok = False - detail = str(error) - else: - config_ok = False - detail = "config.json is missing; run prepare" - checks.append(Check("configured_env", config_ok, True, detail)) - - uv_bin = which("uv") - if uv_bin: - commands = ( - [uv_bin, "lock", "--check"], - [ - uv_bin, - "run", - "--no-sync", - "python", - "-c", - "import mcp; import nocobase_crm_mcp", - ], - ) - labels = ("lockfile", "python_environment") - for label, command in zip(labels, commands, strict=True): - try: - result = runner(command, cwd=str(root), capture_output=True, text=True, check=False) - ok = result.returncode == 0 - detail = "passed" if ok else f"failed with exit code {result.returncode}" - except OSError as error: - ok = False - detail = str(error) - checks.append(Check(label, ok, True, detail)) - else: - checks.extend( - [ - Check("lockfile", False, True, "uv was not found on PATH"), - Check("python_environment", False, True, "uv was not found on PATH"), - ] - ) - - return { - "ok": all(check.ok for check in checks if check.required), - "scope": "local-installation", - "projectRoot": str(root), - "checks": [asdict(check) for check in checks], - "remainingVerification": [ - "Have the installation Agent verify the saved env through nb CLI's default redacted info output.", - "Register the generated stdio MCP configuration in the user's client.", - "Call the MCP tool crm_health and require ok=true.", - ], - } - - -def _print_result(payload: dict[str, Any], *, as_json: bool) -> None: - if as_json: - print(json.dumps(payload, ensure_ascii=False, indent=2)) - return - print(f"Overall: {'ready' if payload.get('ok') else 'action required'}") - if "projectRoot" in payload: - print(f"Project root: {payload['projectRoot']}") - for check in payload.get("checks", []): - marker = "PASS" if check["ok"] else ("FAIL" if check["required"] else "INFO") - print(f"[{marker}] {check['name']}: {check['detail']}") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Prepare and inspect an agent-guided NocoBase CRM MCP installation.") - parser.add_argument("--project-root", type=Path, default=None) - subparsers = parser.add_subparsers(dest="command", required=True) - - for name in ("inspect", "prepare", "doctor"): - command = subparsers.add_parser(name) - command.add_argument("--json", action="store_true", dest="as_json") - - set_env = subparsers.add_parser("set-env") - set_env.add_argument("env_name") - set_env.add_argument("--json", action="store_true", dest="as_json") - - connect_key = subparsers.add_parser("connect-key") - connect_key.add_argument("--api-base-url", required=True) - connect_key.add_argument("--env-name", default=DEFAULT_ENV_NAME) - connect_key.add_argument("--json", action="store_true", dest="as_json") - - client_config = subparsers.add_parser("client-config") - client_config.add_argument("--enable-writes", action="store_true") - return parser - - -def main(argv: Sequence[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - try: - if args.command == "inspect": - payload = inspect_project(args.project_root) - _print_result(payload, as_json=args.as_json) - return 0 if payload["ok"] else 1 - if args.command == "prepare": - payload = prepare_project(args.project_root) - _print_result(payload, as_json=args.as_json) - return 0 - if args.command == "set-env": - payload = set_config_env(args.project_root, args.env_name) - _print_result(payload, as_json=args.as_json) - return 0 - if args.command == "connect-key": - payload = connect_with_personal_key( - args.project_root, - args.api_base_url, - env_name=args.env_name, - ) - _print_result(payload, as_json=args.as_json) - return 0 - if args.command == "client-config": - print(json.dumps(render_client_config(args.project_root, enable_writes=args.enable_writes), indent=2)) - return 0 - if args.command == "doctor": - payload = doctor_project(args.project_root) - _print_result(payload, as_json=args.as_json) - return 0 if payload["ok"] else 1 - except InstallerError as error: - print(json.dumps({"ok": False, "error": {"code": "install_step_failed", "message": str(error)}})) - return 1 - raise AssertionError(f"Unhandled command: {args.command}") +__all__ = [ + "Check", + "DEFAULT_ENV_NAME", + "ENV_NAME_PATTERN", + "InstallerError", + "MIN_PYTHON", + "REQUIRED_PROJECT_FILES", + "Runner", + "SERVER_NAME", + "Which", + "build_parser", + "connect_with_personal_key", + "doctor_project", + "inspect_project", + "main", + "normalize_api_base_url", + "prepare_project", + "render_client_config", + "set_config_env", +] if __name__ == "__main__": diff --git a/scripts/agent_installer/__init__.py b/scripts/agent_installer/__init__.py new file mode 100644 index 0000000..c5b5944 --- /dev/null +++ b/scripts/agent_installer/__init__.py @@ -0,0 +1,15 @@ +from .auth import connect_with_personal_key, normalize_api_base_url +from .cli import main +from .diagnostics import doctor_project +from .project import inspect_project, prepare_project, render_client_config, set_config_env + +__all__ = [ + "connect_with_personal_key", + "doctor_project", + "inspect_project", + "main", + "normalize_api_base_url", + "prepare_project", + "render_client_config", + "set_config_env", +] diff --git a/scripts/agent_installer/auth.py b/scripts/agent_installer/auth.py new file mode 100644 index 0000000..cca7c11 --- /dev/null +++ b/scripts/agent_installer/auth.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from .common import ( + ANSI_ESCAPE_PATTERN, + DEFAULT_ENV_NAME, + ENV_NAME_PATTERN, + Any, + Callable, + InstallerError, + Path, + Runner, + Which, + _project_root, + json, + os, + re, + shutil, + subprocess, + sys, + urlsplit, + urlunsplit, +) +from .project import set_config_env + + +def normalize_api_base_url(value: str) -> str: + raw = value.strip() + if not raw: + raise InstallerError("NocoBase API base URL is required") + parsed = urlsplit(raw) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise InstallerError("NocoBase URL must be an absolute http:// or https:// URL") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise InstallerError("NocoBase URL must not contain credentials, a query string, or a fragment") + hostname = parsed.hostname.lower() + is_local = hostname == "localhost" or hostname == "::1" or hostname.startswith("127.") + if parsed.scheme != "https" and not is_local: + raise InstallerError("A remote NocoBase API Key may only be sent to an https:// endpoint") + path = parsed.path.rstrip("/") + if not path.endswith("/api"): + path = f"{path}/api" if path else "/api" + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + +def _parse_cli_json(output: str) -> dict[str, Any]: + decoder = json.JSONDecoder() + candidates: list[tuple[int, int, dict[str, Any]]] = [] + for start, character in enumerate(output): + if character != "{": + continue + try: + value, end = decoder.raw_decode(output, start) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + candidates.append((start, end, value)) + outer_candidates = [ + candidate + for candidate in candidates + if not any( + other_start <= candidate[0] + and other_end >= candidate[1] + and (other_start, other_end) != (candidate[0], candidate[1]) + for other_start, other_end, _value in candidates + ) + ] + if len(outer_candidates) != 1: + raise InstallerError("nb env info did not return one JSON object") + return outer_candidates[0][2] + + +def _nb_command_environment(nb_bin: str) -> dict[str, str]: + environment = os.environ.copy() + environment["NB_CLI_LOG_DISABLED"] = "1" + environment["NB_SKIP_STARTUP_UPDATE"] = "1" + nb_path = Path(nb_bin) + if nb_path.is_absolute(): + nb_dir = str(nb_path.parent) + existing_path = environment.get("PATH", "") + parts = [part for part in existing_path.split(os.pathsep) if part] + if nb_dir not in parts: + environment["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "") + return environment + + +def _read_nb_environment( + root: Path, + env_name: str, + *, + nb_bin: str, + runner: Runner, +) -> dict[str, Any] | None: + command = [nb_bin, "env", "info", env_name, "--json"] + try: + result = runner( + command, + cwd=str(root), + env=_nb_command_environment(nb_bin), + capture_output=True, + text=True, + check=False, + ) + except OSError as error: + raise InstallerError(f"Could not execute nb env info: {error}") from error + if result.returncode == 0: + return _parse_cli_json(result.stdout) + diagnostic = ANSI_ESCAPE_PATTERN.sub("", f"{result.stdout}\n{result.stderr}") + if "is not configured" in diagnostic: + return None + raise InstallerError( + f"nb env info failed with exit code {result.returncode}; inspect the nb CLI diagnostic locally" + ) + + +def _api_details(payload: dict[str, Any]) -> tuple[str | None, str | None]: + api = payload.get("api") + auth = api.get("auth") if isinstance(api, dict) else None + api_base_url = api.get("apiBaseUrl") if isinstance(api, dict) else None + auth_type = auth.get("type") if isinstance(auth, dict) else None + normalized_url = api_base_url.strip().rstrip("/") if isinstance(api_base_url, str) else None + normalized_type = auth_type.strip() if isinstance(auth_type, str) else None + return normalized_url, normalized_type + + +def _suggest_env_name(api_base_url: str) -> str: + hostname = urlsplit(api_base_url).hostname or "server" + suffix = re.sub(r"[^a-z0-9]+", "-", hostname.lower()).strip("-")[:32] or "server" + return f"{DEFAULT_ENV_NAME}-{suffix}" + + +def connect_with_personal_key( + project_root: str | Path | None, + api_base_url: str, + *, + env_name: str = DEFAULT_ENV_NAME, + runner: Runner = subprocess.run, + which: Which = shutil.which, + stdin_isatty: Callable[[], bool] = sys.stdin.isatty, +) -> dict[str, Any]: + """Bind a personal API Key through nb's private TTY prompt. + + The key is never accepted by this function, placed in argv, inherited from + the process environment, captured, printed, or returned. The child nb CLI + reads it directly from the user-controlled terminal. + """ + + if not ENV_NAME_PATTERN.fullmatch(env_name): + raise InstallerError( + "Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash" + ) + if not stdin_isatty(): + raise InstallerError( + "Secure API Key entry requires a user-controlled local terminal. " + "Do not paste the Key into Agent chat or pass it with --access-token." + ) + root = _project_root(project_root) + normalized_url = normalize_api_base_url(api_base_url) + discovered_nb = which("nb") + if not discovered_nb: + raise InstallerError("nb was not found on PATH") + nb_bin = os.path.abspath(discovered_nb) + existing = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner) + + if existing is None: + interactive_command = [ + nb_bin, + "env", + "add", + env_name, + "--api-base-url", + normalized_url, + "--auth-type", + "token", + ] + action = "created" + else: + existing_url, _auth_type = _api_details(existing) + if existing_url != normalized_url.rstrip("/"): + suggestion = _suggest_env_name(normalized_url) + raise InstallerError( + f"Environment {env_name!r} already targets {existing_url or 'an unconfigured endpoint'!r}; " + f"use a new name such as {suggestion!r} instead of overwriting it" + ) + interactive_command = [nb_bin, "env", "auth", env_name, "--auth-type", "token"] + action = "reauthenticated" + + try: + result = runner( + interactive_command, + cwd=str(root), + env=_nb_command_environment(nb_bin), + text=True, + check=False, + ) + except OSError as error: + raise InstallerError(f"Could not execute the interactive nb Key prompt: {error}") from error + if result.returncode != 0: + raise InstallerError( + f"Interactive API Key authentication failed with exit code {result.returncode}; " + "revoke the exposed or invalid Key if necessary and retry through the installer" + ) + + verified = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner) + if verified is None: + raise InstallerError("nb reported success but the saved environment could not be read back") + verified_url, auth_type = _api_details(verified) + if verified_url != normalized_url.rstrip("/") or auth_type != "token": + raise InstallerError("The saved nb environment did not verify as token authentication for the requested URL") + + list_result = runner( + [nb_bin, "env", "list"], + cwd=str(root), + env=_nb_command_environment(nb_bin), + capture_output=True, + text=True, + check=False, + ) + if list_result.returncode != 0: + raise InstallerError("API Key was saved, but nb env list readback failed") + config_result = set_config_env(root, env_name) + return { + "ok": True, + "action": action, + "env": env_name, + "apiBaseUrl": normalized_url, + "authType": "token", + "keyExposure": "private-tty-only", + "configChanged": config_result["changed"], + "configBackupPath": config_result["backupPath"], + } diff --git a/scripts/agent_installer/cli.py b/scripts/agent_installer/cli.py new file mode 100644 index 0000000..1397ab6 --- /dev/null +++ b/scripts/agent_installer/cli.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from .auth import connect_with_personal_key +from .common import ( + DEFAULT_ENV_NAME, + Any, + InstallerError, + Path, + Sequence, + argparse, + json, +) +from .diagnostics import doctor_project +from .project import inspect_project, prepare_project, render_client_config, set_config_env + + +def _print_result(payload: dict[str, Any], *, as_json: bool) -> None: + if as_json: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + print(f"Overall: {'ready' if payload.get('ok') else 'action required'}") + if "projectRoot" in payload: + print(f"Project root: {payload['projectRoot']}") + for check in payload.get("checks", []): + marker = "PASS" if check["ok"] else ("FAIL" if check["required"] else "INFO") + print(f"[{marker}] {check['name']}: {check['detail']}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Prepare and inspect an agent-guided NocoBase CRM MCP installation.") + parser.add_argument("--project-root", type=Path, default=None) + subparsers = parser.add_subparsers(dest="command", required=True) + + for name in ("inspect", "prepare", "doctor"): + command = subparsers.add_parser(name) + command.add_argument("--json", action="store_true", dest="as_json") + + set_env = subparsers.add_parser("set-env") + set_env.add_argument("env_name") + set_env.add_argument("--json", action="store_true", dest="as_json") + + connect_key = subparsers.add_parser("connect-key") + connect_key.add_argument("--api-base-url", required=True) + connect_key.add_argument("--env-name", default=DEFAULT_ENV_NAME) + connect_key.add_argument("--json", action="store_true", dest="as_json") + + client_config = subparsers.add_parser("client-config") + client_config.add_argument("--enable-writes", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.command == "inspect": + payload = inspect_project(args.project_root) + _print_result(payload, as_json=args.as_json) + return 0 if payload["ok"] else 1 + if args.command == "prepare": + payload = prepare_project(args.project_root) + _print_result(payload, as_json=args.as_json) + return 0 + if args.command == "set-env": + payload = set_config_env(args.project_root, args.env_name) + _print_result(payload, as_json=args.as_json) + return 0 + if args.command == "connect-key": + payload = connect_with_personal_key( + args.project_root, + args.api_base_url, + env_name=args.env_name, + ) + _print_result(payload, as_json=args.as_json) + return 0 + if args.command == "client-config": + print(json.dumps(render_client_config(args.project_root, enable_writes=args.enable_writes), indent=2)) + return 0 + if args.command == "doctor": + payload = doctor_project(args.project_root) + _print_result(payload, as_json=args.as_json) + return 0 if payload["ok"] else 1 + except InstallerError as error: + print(json.dumps({"ok": False, "error": {"code": "install_step_failed", "message": str(error)}})) + return 1 + raise AssertionError(f"Unhandled command: {args.command}") diff --git a/scripts/agent_installer/common.py b/scripts/agent_installer/common.py new file mode 100644 index 0000000..e52d85a --- /dev/null +++ b/scripts/agent_installer/common.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Callable, Sequence +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +MIN_PYTHON = (3, 12) + +DEFAULT_ENV_NAME = "nb-mcp" + +SERVER_NAME = "nocobase-crm" + +REQUIRED_PROJECT_FILES = ("pyproject.toml", "uv.lock", "config.example.json") + +ENV_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + +ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + +Which = Callable[[str], str | None] + +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +class InstallerError(RuntimeError): + """Raised when a deterministic installation step cannot finish safely.""" + + +@dataclass(frozen=True) +class Check: + name: str + ok: bool + required: bool + detail: str + + +def _project_root(path: str | Path | None) -> Path: + candidate = Path(path) if path is not None else Path(__file__).resolve().parent.parent + return candidate.expanduser().resolve() + + +def _load_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise InstallerError(f"Could not read valid JSON from {path}: {error}") from error + if not isinstance(value, dict): + raise InstallerError(f"Expected a JSON object in {path}") + return value + + +__all__ = [ + "ANSI_ESCAPE_PATTERN", + "Any", + "Callable", + "Check", + "DEFAULT_ENV_NAME", + "ENV_NAME_PATTERN", + "InstallerError", + "MIN_PYTHON", + "Path", + "REQUIRED_PROJECT_FILES", + "Runner", + "SERVER_NAME", + "Sequence", + "Which", + "_load_json_object", + "_project_root", + "argparse", + "asdict", + "json", + "os", + "re", + "shutil", + "subprocess", + "sys", + "tempfile", + "urlsplit", + "urlunsplit", +] diff --git a/scripts/agent_installer/diagnostics.py b/scripts/agent_installer/diagnostics.py new file mode 100644 index 0000000..1ee54d3 --- /dev/null +++ b/scripts/agent_installer/diagnostics.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from .common import ( + Any, + Check, + InstallerError, + Path, + Runner, + Which, + _load_json_object, + _project_root, + asdict, + shutil, + subprocess, +) +from .project import inspect_project + + +def doctor_project( + project_root: str | Path | None = None, + *, + runner: Runner = subprocess.run, + which: Which = shutil.which, + python_version: tuple[int, int, int] | None = None, +) -> dict[str, Any]: + root = _project_root(project_root) + inspection = inspect_project(root, which=which, python_version=python_version) + checks = [Check(**item) for item in inspection["checks"]] + + config_path = root / "config.json" + if config_path.is_file(): + try: + config = _load_json_object(config_path) + env_name = config.get("env") + config_ok = isinstance(env_name, str) and bool(env_name.strip()) + detail = f"config env is {env_name!r}" if config_ok else "config.json has no non-empty env" + except InstallerError as error: + config_ok = False + detail = str(error) + else: + config_ok = False + detail = "config.json is missing; run prepare" + checks.append(Check("configured_env", config_ok, True, detail)) + + uv_bin = which("uv") + if uv_bin: + commands = ( + [uv_bin, "lock", "--check"], + [ + uv_bin, + "run", + "--no-sync", + "python", + "-c", + "import mcp; import nocobase_crm_mcp", + ], + ) + labels = ("lockfile", "python_environment") + for label, command in zip(labels, commands, strict=True): + try: + result = runner(command, cwd=str(root), capture_output=True, text=True, check=False) + ok = result.returncode == 0 + detail = "passed" if ok else f"failed with exit code {result.returncode}" + except OSError as error: + ok = False + detail = str(error) + checks.append(Check(label, ok, True, detail)) + else: + checks.extend( + [ + Check("lockfile", False, True, "uv was not found on PATH"), + Check("python_environment", False, True, "uv was not found on PATH"), + ] + ) + + return { + "ok": all(check.ok for check in checks if check.required), + "scope": "local-installation", + "projectRoot": str(root), + "checks": [asdict(check) for check in checks], + "remainingVerification": [ + "Have the installation Agent verify the saved env through nb CLI's default redacted info output.", + "Register the generated stdio MCP configuration in the user's client.", + "Call the MCP tool crm_health and require ok=true.", + ], + } diff --git a/scripts/agent_installer/project.py b/scripts/agent_installer/project.py new file mode 100644 index 0000000..7b49207 --- /dev/null +++ b/scripts/agent_installer/project.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from .common import ( + ENV_NAME_PATTERN, + MIN_PYTHON, + REQUIRED_PROJECT_FILES, + SERVER_NAME, + Any, + Check, + InstallerError, + Path, + Runner, + Which, + _load_json_object, + _project_root, + asdict, + json, + os, + shutil, + subprocess, + sys, + tempfile, +) + + +def inspect_project( + project_root: str | Path | None = None, + *, + which: Which = shutil.which, + python_version: tuple[int, int, int] | None = None, +) -> dict[str, Any]: + root = _project_root(project_root) + version = python_version or (sys.version_info.major, sys.version_info.minor, sys.version_info.micro) + commands = {name: which(name) for name in ("git", "uv", "nb")} + missing_files = [name for name in REQUIRED_PROJECT_FILES if not (root / name).is_file()] + checks = [ + Check( + "python", + version[:2] >= MIN_PYTHON, + True, + f"Python {version[0]}.{version[1]}.{version[2]} (requires >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]})", + ), + Check("git", bool(commands["git"]), True, commands["git"] or "git was not found on PATH"), + Check("uv", bool(commands["uv"]), True, commands["uv"] or "uv was not found on PATH"), + Check("nb", bool(commands["nb"]), True, commands["nb"] or "nb was not found on PATH"), + Check( + "project_files", + not missing_files, + True, + "required project files are present" if not missing_files else f"missing: {', '.join(missing_files)}", + ), + Check( + "config", + (root / "config.json").is_file(), + False, + "config.json is present" if (root / "config.json").is_file() else "config.json will be created by prepare", + ), + ] + return { + "ok": all(check.ok for check in checks if check.required), + "projectRoot": str(root), + "checks": [asdict(check) for check in checks], + } + + +def _require_prepare_prerequisites( + root: Path, + *, + which: Which, + python_version: tuple[int, int, int], +) -> str: + inspection = inspect_project(root, which=which, python_version=python_version) + required = {check["name"]: check for check in inspection["checks"] if check["required"]} + blocking = [required[name]["detail"] for name in ("python", "uv", "project_files") if not required[name]["ok"]] + if blocking: + raise InstallerError("; ".join(blocking)) + uv_bin = which("uv") + if not uv_bin: + raise InstallerError("uv was not found on PATH") + return os.path.abspath(uv_bin) + + +def prepare_project( + project_root: str | Path | None = None, + *, + runner: Runner = subprocess.run, + which: Which = shutil.which, + python_version: tuple[int, int, int] | None = None, +) -> dict[str, Any]: + root = _project_root(project_root) + version = python_version or (sys.version_info.major, sys.version_info.minor, sys.version_info.micro) + uv_bin = _require_prepare_prerequisites(root, which=which, python_version=version) + example_path = root / "config.example.json" + config_path = root / "config.json" + _load_json_object(example_path) + + created_config = False + if config_path.exists(): + _load_json_object(config_path) + else: + shutil.copy2(example_path, config_path) + created_config = True + + command = [uv_bin, "sync", "--locked", "--python", f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"] + try: + result = runner(command, cwd=str(root), capture_output=True, text=True, check=False) + except OSError as error: + raise InstallerError(f"Could not execute uv sync: {error}") from error + if result.returncode != 0: + raise InstallerError( + f"uv sync --locked failed with exit code {result.returncode}; rerun it directly to inspect package-manager output" + ) + + return { + "ok": True, + "projectRoot": str(root), + "configPath": str(config_path), + "configCreated": created_config, + "dependencies": "locked", + } + + +def set_config_env(project_root: str | Path | None, env_name: str) -> dict[str, Any]: + if not ENV_NAME_PATTERN.fullmatch(env_name): + raise InstallerError( + "Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash" + ) + root = _project_root(project_root) + config_path = root / "config.json" + config = _load_json_object(config_path) + previous = config.get("env") + if previous == env_name: + return { + "ok": True, + "changed": False, + "configPath": str(config_path), + "env": env_name, + "backupPath": None, + } + + backup_path = root / "config.json.pre-agent-install.bak" + if not backup_path.exists(): + shutil.copy2(config_path, backup_path) + config["env"] = env_name + + fd, temporary_name = tempfile.mkstemp(prefix=".config.json.", dir=root) + temporary_path = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(config, handle, ensure_ascii=False, indent=2) + handle.write("\n") + os.replace(temporary_path, config_path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + return { + "ok": True, + "changed": True, + "configPath": str(config_path), + "env": env_name, + "previousEnv": previous, + "backupPath": str(backup_path), + } + + +def render_client_config( + project_root: str | Path | None = None, + *, + enable_writes: bool = False, + which: Which = shutil.which, +) -> dict[str, Any]: + root = _project_root(project_root) + uv_bin = which("uv") or "uv" + nb_bin = which("nb") or "nb" + if os.path.isabs(uv_bin): + uv_bin = os.path.abspath(uv_bin) + if os.path.isabs(nb_bin): + nb_bin = os.path.abspath(nb_bin) + args = [ + "--directory", + str(root), + "run", + "--no-sync", + "nocobase-crm-mcp", + "--config", + str(root / "config.json"), + ] + if enable_writes: + args.append("--enable-writes") + return { + "mcpServers": { + SERVER_NAME: { + "command": uv_bin, + "args": args, + "env": {"NOCOBASE_MCP_NB_BIN": nb_bin}, + } + } + } diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index a0420a3..2b225b1 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -1,570 +1,98 @@ -#!/usr/bin/env python3 -"""Manage the user-level HTTP NocoBase CRM MCP systemd service.""" - from __future__ import annotations -import argparse -import os -import secrets -import shlex -import shutil -import socket -import subprocess -import time -import urllib.error -import urllib.request -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path - -SERVICE_NAME = "nocobase-crm-mcp.service" -ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env" -DEFAULT_HOST = "127.0.0.1" -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" -DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 -ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS" -API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL" -API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN" -LEGACY_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL" -LEGACY_API_TOKEN_ENV = "NOCOBASE_MCP_UPLOAD_JWT" -MANAGED_ENV_NAMES = frozenset( - { - "NOCOBASE_MCP_TOKEN", - "NOCOBASE_MCP_HOST", - "NOCOBASE_MCP_PORT", - "NOCOBASE_MCP_PATH", - "NOCOBASE_MCP_ENV", - "NOCOBASE_MCP_CONFIG", - "NOCOBASE_MCP_NB_BIN", - ENABLE_UPLOADS_ENV, - "NOCOBASE_MCP_UPLOAD_MAX_BYTES", - } +from mcp_service_cli import main +from mcp_service_config import ( + API_BASE_URL_ENV, + API_TOKEN_ENV, + DEFAULT_CONFIG_PATH, + DEFAULT_HOST, + DEFAULT_NB_BIN, + DEFAULT_NB_ENV, + DEFAULT_PATH, + DEFAULT_PORT, + DEFAULT_TOKEN_BYTES, + DEFAULT_UPLOAD_MAX_BYTES, + DEFAULT_UV_CACHE_DIR, + ENABLE_UPLOADS_ENV, + ENV_RELATIVE_PATH, + LEGACY_API_BASE_URL_ENV, + LEGACY_API_TOKEN_ENV, + MANAGED_ENV_NAMES, + SERVICE_NAME, + ServiceConfig, + ServicePaths, + find_uv_path, + read_service_env_file, + render_service_unit, + resolve_service_paths, + saved_service_config, + systemd_quote, + write_token_env_file, +) +from mcp_service_runtime import ( + detect_remote_hosts, + find_nb_path, + generate_token, + new_token, + probe_http_service, + render_remote_config, + restart_service_if_installed, + run_systemctl, + service_state, + service_status, + show_config, + start_service, + stop_service, + uninstall_service, + wait_for_http_service, + write_service_unit, ) - -@dataclass(frozen=True) -class ServicePaths: - project_root: Path - unit_path: Path - env_path: Path - - -def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str: - return secrets.token_urlsafe(num_bytes) - - -def systemd_quote(value: str | Path) -> str: - return shlex.quote(str(value)) - - -def find_uv_path() -> Path: - configured = os.environ.get("UV_PATH", "").strip() - discovered = configured or shutil.which("uv") - if not discovered: - raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute 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")) - unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME - return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH) - - -def render_service_unit( - *, - project_root: Path, - env_file: Path, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - uv_cache_dir: str = DEFAULT_UV_CACHE_DIR, - uv_path: Path | None = None, -) -> str: - resolved_uv_path = uv_path or find_uv_path() - command = " ".join( - [ - systemd_quote(resolved_uv_path), - "run", - "--no-sync", - "nocobase-crm-mcp", - "--transport", - "streamable-http", - "--host", - host, - "--port", - str(port), - "--path", - path, - ] - ) - return ( - "[Unit]\n" - "Description=NocoBase CRM HTTP MCP server\n" - "After=network-online.target\n" - "Wants=network-online.target\n" - "\n" - "[Service]\n" - "Type=simple\n" - f"WorkingDirectory={systemd_quote(project_root)}\n" - f"EnvironmentFile={systemd_quote(env_file)}\n" - f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n" - f"ExecStart={command}\n" - "Restart=on-failure\n" - "RestartSec=5\n" - "\n" - "[Install]\n" - "WantedBy=default.target\n" - ) - - -def write_token_env_file( - env_path: Path, - token: str, - *, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - nb_env: str = DEFAULT_NB_ENV, - config_path: str = DEFAULT_CONFIG_PATH, - nb_bin: str | Path = DEFAULT_NB_BIN, -) -> None: - uploads_enabled = _saved_uploads_setting(env_path) - preserved_lines = _operator_env_lines(env_path) - env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.parent.chmod(0o700) - fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as env_file: - env_file.write(f"NOCOBASE_MCP_TOKEN={token}\n") - env_file.write(f"NOCOBASE_MCP_HOST={host}\n") - env_file.write(f"NOCOBASE_MCP_PORT={port}\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_CONFIG={config_path}\n") - env_file.write(f"NOCOBASE_MCP_NB_BIN={nb_bin}\n") - env_file.write(f"{ENABLE_UPLOADS_ENV}={uploads_enabled}\n") - env_file.write(f"NOCOBASE_MCP_UPLOAD_MAX_BYTES={DEFAULT_UPLOAD_MAX_BYTES}\n") - if preserved_lines: - env_file.write("\n") - env_file.write("\n".join(preserved_lines)) - env_file.write("\n") - env_path.chmod(0o600) - - -def _saved_uploads_setting(env_path: Path) -> str: - if not env_path.exists(): - return "0" - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - key, separator, value = raw_line.strip().partition("=") - if separator and key.strip() == ENABLE_UPLOADS_ENV: - return "1" if value.strip() == "1" else "0" - return "0" - - -def _operator_env_lines(env_path: Path) -> list[str]: - if not env_path.exists(): - return [] - preserved_lines: list[str] = [] - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - key, separator, _value = raw_line.strip().partition("=") - if separator and key.strip() in MANAGED_ENV_NAMES: - continue - preserved_lines.append(raw_line) - return preserved_lines - - -def read_service_env_file(env_path: Path) -> dict[str, str]: - if not env_path.exists(): - raise RuntimeError( - f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it, " - "or scripts/mcp_service.sh new-token to rotate it." - ) - values: dict[str, str] = {} - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - key, separator, value = line.partition("=") - if separator: - values[key.strip()] = value.strip() - return values - - -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() - if not token: - raise RuntimeError(f"Missing NOCOBASE_MCP_TOKEN in {paths.env_path}") - host = values.get("NOCOBASE_MCP_HOST") or DEFAULT_HOST - port = int(values.get("NOCOBASE_MCP_PORT") or DEFAULT_PORT) - 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 - 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: - paths.unit_path.parent.mkdir(parents=True, exist_ok=True) - unit = render_service_unit( - project_root=paths.project_root, env_file=paths.env_path, host=host, port=port, path=path - ) - paths.unit_path.write_text(unit, encoding="utf-8") - - -def detect_remote_hosts() -> list[str]: - hosts: list[str] = [] - try: - result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False) - except OSError: - result = None - if result and result.returncode == 0: - hosts.extend(result.stdout.split()) - try: - for info in socket.getaddrinfo(socket.gethostname(), None): - hosts.append(info[4][0]) - except OSError: - pass - cleaned: list[str] = [] - for host in hosts: - normalized = host.strip() - if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"): - continue - if normalized not in cleaned: - cleaned.append(normalized) - return cleaned - - -def render_remote_config( - *, - hosts: Sequence[str], - token: str, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - service_status: str | None = None, -) -> str: - host = hosts[0] if hosts else "127.0.0.1" - url = f"http://{host}:{port}{path}" - alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:]) - alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else "" - status_section = f"Service status: {service_status}\n\n" if service_status else "" - return ( - "Saved MCP client config.\n" - "\n" - f"{status_section}" - "Use this loopback URL on this machine:\n" - f"{url}\n" - f"{alternatives_section}" - "\n" - "Use this header:\n" - f"Authorization: Bearer {token}\n" - "\n" - "Example MCP client config:\n" - "{\n" - ' "mcpServers": {\n' - ' "nocobase-crm": {\n' - ' "type": "streamable-http",\n' - f' "url": "{url}",\n' - ' "headers": {\n' - f' "Authorization": "Bearer {token}"\n' - " }\n" - " }\n" - " }\n" - "}\n" - ) - - -def show_config(project_root: Path) -> str: - token, host, port, path, _nb_env, _config_path, _nb_bin = saved_service_config(project_root) - display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host - return render_remote_config( - hosts=[display_host], - token=token, - port=port, - path=path, - service_status=service_state(), - ) - - -def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run(["systemctl", "--user", *args], text=True, check=check) - - -def service_state() -> str: - result = subprocess.run( - ["systemctl", "--user", "is-active", SERVICE_NAME], - capture_output=True, - text=True, - check=False, - ) - state = result.stdout.strip() - if state: - return state - if result.returncode == 0: - return "active" - return "unknown" - - -def probe_http_service(host: str, port: int, path: str, *, token: str, timeout: float = 2.0) -> dict[str, object]: - url = f"http://{host}:{port}{path}" - request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET") - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - return {"reachable": True, "status": response.status, "url": url} - except urllib.error.HTTPError as error: - return {"reachable": True, "status": error.code, "url": url} - except OSError as error: - 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 - run_systemctl(["restart", SERVICE_NAME]) - return True - - -def new_token( - *, - project_root: Path, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - nb_env: str = DEFAULT_NB_ENV, - config_path: str = DEFAULT_CONFIG_PATH, - token_bytes: int = DEFAULT_TOKEN_BYTES, -) -> 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, - nb_bin=find_nb_path(), - ) - restart_service_if_installed(paths) - display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host - return render_remote_config(hosts=[display_host], token=token, port=port, path=path, service_status=service_state()) - - -def start_service(*, project_root: Path) -> str: - paths = resolve_service_paths(project_root) - nb_bin = find_nb_path() - if not paths.env_path.exists(): - write_token_env_file( - paths.env_path, - generate_token(), - host=DEFAULT_HOST, - port=DEFAULT_PORT, - 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_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]) - run_systemctl(["restart", SERVICE_NAME]) - try: - 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 = 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')}") - display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host - return render_remote_config(hosts=[display_host], token=token, port=port, path=path, service_status="active") - - -def stop_service() -> None: - run_systemctl(["stop", SERVICE_NAME]) - - -def uninstall_service(project_root: Path) -> None: - paths = resolve_service_paths(project_root) - run_systemctl(["disable", "--now", SERVICE_NAME], check=False) - if paths.unit_path.exists(): - paths.unit_path.unlink() - if paths.env_path.exists(): - paths.env_path.unlink() - try: - paths.env_path.parent.rmdir() - except OSError: - pass - run_systemctl(["daemon-reload"], check=False) - run_systemctl(["reset-failed", SERVICE_NAME], check=False) - - -def service_status(*, project_root: Path) -> str: - paths = resolve_service_paths(project_root) - state = service_state() - lines = [ - f"Service status: {state}", - f"Unit file: {paths.unit_path}", - f"Env file: {paths.env_path}", - ] - if paths.env_path.exists(): - values = read_service_env_file(paths.env_path) - token, host, port, path, nb_env, _config_path, nb_bin = saved_service_config(project_root) - display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host - lines.append(f"MCP URL: http://{display_host}:{port}{path}") - lines.append(f"nb binary: {nb_bin}") - lines.append(f"NocoBase CLI env: {nb_env} (personal API Key managed by the Agent installer)") - uploads_enabled = values.get(ENABLE_UPLOADS_ENV) == "1" - lines.append(f"Attachment uploads: {'enabled' if uploads_enabled else 'disabled (safe default)'}") - canonical_configured = bool(values.get(API_BASE_URL_ENV) and values.get(API_TOKEN_ENV)) - legacy_configured = bool(values.get(LEGACY_API_BASE_URL_ENV) and values.get(LEGACY_API_TOKEN_ENV)) - if canonical_configured: - credential_state = "configured (canonical)" - elif legacy_configured: - credential_state = "configured (legacy)" - elif any( - values.get(name) - for name in (API_BASE_URL_ENV, API_TOKEN_ENV, LEGACY_API_BASE_URL_ENV, LEGACY_API_TOKEN_ENV) - ): - credential_state = "incomplete" - else: - credential_state = "not configured; using the local nb CLI login" - lines.append(f"Legacy NocoBase credential override: {credential_state}") - probe = probe_http_service("127.0.0.1", port, path, token=token) - if probe.get("reachable"): - lines.append(f"HTTP probe: reachable (status {probe.get('status')})") - else: - lines.append(f"HTTP probe: not reachable ({probe.get('error')})") - else: - lines.append("MCP URL: unavailable until first start creates the env file") - lines.append("HTTP probe: skipped") - return "\n".join(lines) + "\n" - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Manage the HTTP NocoBase CRM MCP user service.") - subparsers = parser.add_subparsers(dest="command", required=True) - - new_token_parser = subparsers.add_parser( - "new-token", help="Generate a new token and print local MCP client configuration." - ) - new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) - new_token_parser.add_argument("--host", default=DEFAULT_HOST) - new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int) - new_token_parser.add_argument("--path", default=DEFAULT_PATH) - new_token_parser.add_argument("--nb-env", default=DEFAULT_NB_ENV) - new_token_parser.add_argument("--config-path", default=DEFAULT_CONFIG_PATH) - new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int) - - start_parser = subparsers.add_parser( - "start", help="Install or update the user service using the saved token, then start it." - ) - start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) - - subparsers.add_parser("stop", help="Stop the user service without deleting service files.") - - uninstall_parser = subparsers.add_parser( - "uninstall", help="Stop and remove the managed user service and token file." - ) - uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) - - show_config_parser = subparsers.add_parser( - "show-config", help="Print the saved token and local MCP client configuration." - ) - show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) - - status_parser = subparsers.add_parser("status", help="Show systemd status for the managed service.") - status_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) - - args = parser.parse_args(argv) - if args.command == "new-token": - print( - new_token( - project_root=args.project_root, - host=args.host, - port=args.port, - path=args.path, - nb_env=args.nb_env, - config_path=args.config_path, - token_bytes=args.token_bytes, - ), - end="", - ) - return 0 - if args.command == "start": - print(start_service(project_root=args.project_root), end="") - return 0 - if args.command == "stop": - stop_service() - print(f"Stopped {SERVICE_NAME}.") - return 0 - if args.command == "uninstall": - uninstall_service(args.project_root) - print(f"Uninstalled {SERVICE_NAME}.") - return 0 - if args.command == "show-config": - print(show_config(args.project_root), end="") - return 0 - if args.command == "status": - print(service_status(project_root=args.project_root), end="") - return 0 - raise AssertionError(f"unhandled command: {args.command}") +__all__ = [ + "SERVICE_NAME", + "ENV_RELATIVE_PATH", + "DEFAULT_HOST", + "DEFAULT_PORT", + "DEFAULT_PATH", + "DEFAULT_NB_ENV", + "DEFAULT_CONFIG_PATH", + "DEFAULT_NB_BIN", + "DEFAULT_TOKEN_BYTES", + "DEFAULT_UV_CACHE_DIR", + "DEFAULT_UPLOAD_MAX_BYTES", + "ENABLE_UPLOADS_ENV", + "API_BASE_URL_ENV", + "API_TOKEN_ENV", + "LEGACY_API_BASE_URL_ENV", + "LEGACY_API_TOKEN_ENV", + "MANAGED_ENV_NAMES", + "ServicePaths", + "ServiceConfig", + "systemd_quote", + "find_uv_path", + "resolve_service_paths", + "render_service_unit", + "write_token_env_file", + "read_service_env_file", + "saved_service_config", + "generate_token", + "find_nb_path", + "write_service_unit", + "detect_remote_hosts", + "render_remote_config", + "show_config", + "run_systemctl", + "service_state", + "probe_http_service", + "wait_for_http_service", + "restart_service_if_installed", + "new_token", + "start_service", + "stop_service", + "uninstall_service", + "service_status", + "main", +] if __name__ == "__main__": diff --git a/scripts/mcp_service_cli.py b/scripts/mcp_service_cli.py new file mode 100644 index 0000000..6af4c4e --- /dev/null +++ b/scripts/mcp_service_cli.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from mcp_service_config import ( + DEFAULT_CONFIG_PATH, + DEFAULT_HOST, + DEFAULT_NB_ENV, + DEFAULT_PATH, + DEFAULT_PORT, + DEFAULT_TOKEN_BYTES, + SERVICE_NAME, +) +from mcp_service_runtime import new_token, service_status, show_config, start_service, stop_service, uninstall_service + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Manage the HTTP NocoBase CRM MCP user service.") + subparsers = parser.add_subparsers(dest="command", required=True) + + new_token_parser = subparsers.add_parser( + "new-token", help="Generate a new token and print local MCP client configuration." + ) + new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) + new_token_parser.add_argument("--host", default=DEFAULT_HOST) + new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int) + new_token_parser.add_argument("--path", default=DEFAULT_PATH) + new_token_parser.add_argument("--nb-env", default=DEFAULT_NB_ENV) + new_token_parser.add_argument("--config-path", default=DEFAULT_CONFIG_PATH) + new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int) + + start_parser = subparsers.add_parser( + "start", help="Install or update the user service using the saved token, then start it." + ) + start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) + + subparsers.add_parser("stop", help="Stop the user service without deleting service files.") + + uninstall_parser = subparsers.add_parser( + "uninstall", help="Stop and remove the managed user service and token file." + ) + uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) + + show_config_parser = subparsers.add_parser( + "show-config", help="Print the saved token and local MCP client configuration." + ) + show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) + + status_parser = subparsers.add_parser("status", help="Show systemd status for the managed service.") + status_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) + + args = parser.parse_args(argv) + if args.command == "new-token": + print( + new_token( + project_root=args.project_root, + host=args.host, + port=args.port, + path=args.path, + nb_env=args.nb_env, + config_path=args.config_path, + token_bytes=args.token_bytes, + ), + end="", + ) + return 0 + if args.command == "start": + print(start_service(project_root=args.project_root), end="") + return 0 + if args.command == "stop": + stop_service() + print(f"Stopped {SERVICE_NAME}.") + return 0 + if args.command == "uninstall": + uninstall_service(args.project_root) + print(f"Uninstalled {SERVICE_NAME}.") + return 0 + if args.command == "show-config": + print(show_config(args.project_root), end="") + return 0 + if args.command == "status": + print(service_status(project_root=args.project_root), end="") + return 0 + raise AssertionError(f"unhandled command: {args.command}") + + +__all__ = ["main"] diff --git a/scripts/mcp_service_config.py b/scripts/mcp_service_config.py new file mode 100644 index 0000000..fe20fd0 --- /dev/null +++ b/scripts/mcp_service_config.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import os +import shlex +import shutil +from dataclasses import dataclass +from pathlib import Path + +SERVICE_NAME = "nocobase-crm-mcp.service" + +ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env" + +DEFAULT_HOST = "127.0.0.1" + +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" + +DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 + +ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS" + +API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL" + +API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN" + +LEGACY_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL" + +LEGACY_API_TOKEN_ENV = "NOCOBASE_MCP_UPLOAD_JWT" + +MANAGED_ENV_NAMES = frozenset( + { + "NOCOBASE_MCP_TOKEN", + "NOCOBASE_MCP_HOST", + "NOCOBASE_MCP_PORT", + "NOCOBASE_MCP_PATH", + "NOCOBASE_MCP_ENV", + "NOCOBASE_MCP_CONFIG", + "NOCOBASE_MCP_NB_BIN", + ENABLE_UPLOADS_ENV, + "NOCOBASE_MCP_UPLOAD_MAX_BYTES", + } +) + + +@dataclass(frozen=True) +class ServicePaths: + project_root: Path + unit_path: Path + env_path: Path + + +@dataclass(frozen=True) +class ServiceConfig: + token: str + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + path: str = DEFAULT_PATH + nb_env: str = DEFAULT_NB_ENV + config_path: str = DEFAULT_CONFIG_PATH + nb_bin: str = DEFAULT_NB_BIN + + +def systemd_quote(value: str | Path) -> str: + return shlex.quote(str(value)) + + +def find_uv_path() -> Path: + configured = os.environ.get("UV_PATH", "").strip() + discovered = configured or shutil.which("uv") + if not discovered: + raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute path.") + return Path(discovered).expanduser().resolve() + + +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")) + unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME + return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH) + + +def render_service_unit( + *, + project_root: Path, + env_file: Path, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + path: str = DEFAULT_PATH, + uv_cache_dir: str = DEFAULT_UV_CACHE_DIR, + uv_path: Path | None = None, +) -> str: + resolved_uv_path = uv_path or find_uv_path() + command = " ".join( + [ + systemd_quote(resolved_uv_path), + "run", + "--no-sync", + "nocobase-crm-mcp", + "--transport", + "streamable-http", + "--host", + host, + "--port", + str(port), + "--path", + path, + ] + ) + return ( + "[Unit]\n" + "Description=NocoBase CRM HTTP MCP server\n" + "After=network-online.target\n" + "Wants=network-online.target\n" + "\n" + "[Service]\n" + "Type=simple\n" + f"WorkingDirectory={systemd_quote(project_root)}\n" + f"EnvironmentFile={systemd_quote(env_file)}\n" + f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n" + f"ExecStart={command}\n" + "Restart=on-failure\n" + "RestartSec=5\n" + "\n" + "[Install]\n" + "WantedBy=default.target\n" + ) + + +def write_token_env_file( + env_path: Path, + token: str, + *, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + path: str = DEFAULT_PATH, + nb_env: str = DEFAULT_NB_ENV, + config_path: str = DEFAULT_CONFIG_PATH, + nb_bin: str | Path = DEFAULT_NB_BIN, +) -> None: + uploads_enabled = _saved_uploads_setting(env_path) + preserved_lines = _operator_env_lines(env_path) + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.parent.chmod(0o700) + fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as env_file: + env_file.write(f"NOCOBASE_MCP_TOKEN={token}\n") + env_file.write(f"NOCOBASE_MCP_HOST={host}\n") + env_file.write(f"NOCOBASE_MCP_PORT={port}\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_CONFIG={config_path}\n") + env_file.write(f"NOCOBASE_MCP_NB_BIN={nb_bin}\n") + env_file.write(f"{ENABLE_UPLOADS_ENV}={uploads_enabled}\n") + env_file.write(f"NOCOBASE_MCP_UPLOAD_MAX_BYTES={DEFAULT_UPLOAD_MAX_BYTES}\n") + if preserved_lines: + env_file.write("\n") + env_file.write("\n".join(preserved_lines)) + env_file.write("\n") + env_path.chmod(0o600) + + +def _saved_uploads_setting(env_path: Path) -> str: + if not env_path.exists(): + return "0" + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + key, separator, value = raw_line.strip().partition("=") + if separator and key.strip() == ENABLE_UPLOADS_ENV: + return "1" if value.strip() == "1" else "0" + return "0" + + +def _operator_env_lines(env_path: Path) -> list[str]: + if not env_path.exists(): + return [] + preserved_lines: list[str] = [] + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + key, separator, _value = raw_line.strip().partition("=") + if separator and key.strip() in MANAGED_ENV_NAMES: + continue + preserved_lines.append(raw_line) + return preserved_lines + + +def read_service_env_file(env_path: Path) -> dict[str, str]: + if not env_path.exists(): + raise RuntimeError( + f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it, " + "or scripts/mcp_service.sh new-token to rotate it." + ) + values: dict[str, str] = {} + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + key, separator, value = line.partition("=") + if separator: + values[key.strip()] = value.strip() + return values + + +def saved_service_config(project_root: Path) -> ServiceConfig: + paths = resolve_service_paths(project_root) + values = read_service_env_file(paths.env_path) + token = values.get("NOCOBASE_MCP_TOKEN", "").strip() + if not token: + raise RuntimeError(f"Missing NOCOBASE_MCP_TOKEN in {paths.env_path}") + host = values.get("NOCOBASE_MCP_HOST") or DEFAULT_HOST + port = int(values.get("NOCOBASE_MCP_PORT") or DEFAULT_PORT) + 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 + nb_bin = values.get("NOCOBASE_MCP_NB_BIN") or DEFAULT_NB_BIN + return ServiceConfig( + token=token, + host=host, + port=port, + path=path, + nb_env=nb_env, + config_path=config_path, + nb_bin=nb_bin, + ) + + +__all__ = [ + "API_BASE_URL_ENV", + "API_TOKEN_ENV", + "DEFAULT_CONFIG_PATH", + "DEFAULT_HOST", + "DEFAULT_NB_BIN", + "DEFAULT_NB_ENV", + "DEFAULT_PATH", + "DEFAULT_PORT", + "DEFAULT_TOKEN_BYTES", + "DEFAULT_UPLOAD_MAX_BYTES", + "DEFAULT_UV_CACHE_DIR", + "ENABLE_UPLOADS_ENV", + "ENV_RELATIVE_PATH", + "LEGACY_API_BASE_URL_ENV", + "LEGACY_API_TOKEN_ENV", + "MANAGED_ENV_NAMES", + "SERVICE_NAME", + "ServiceConfig", + "ServicePaths", + "_operator_env_lines", + "_saved_uploads_setting", + "find_uv_path", + "read_service_env_file", + "render_service_unit", + "resolve_service_paths", + "saved_service_config", + "systemd_quote", + "write_token_env_file", +] diff --git a/scripts/mcp_service_runtime.py b/scripts/mcp_service_runtime.py new file mode 100644 index 0000000..78ae0ad --- /dev/null +++ b/scripts/mcp_service_runtime.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import os +import secrets +import shutil +import socket +import subprocess +import time +import urllib.error +import urllib.request +from collections.abc import Sequence +from pathlib import Path + +from mcp_service_config import ( + API_BASE_URL_ENV, + API_TOKEN_ENV, + DEFAULT_CONFIG_PATH, + DEFAULT_HOST, + DEFAULT_NB_ENV, + DEFAULT_PATH, + DEFAULT_PORT, + DEFAULT_TOKEN_BYTES, + ENABLE_UPLOADS_ENV, + LEGACY_API_BASE_URL_ENV, + LEGACY_API_TOKEN_ENV, + SERVICE_NAME, + ServicePaths, + find_uv_path, + read_service_env_file, + render_service_unit, + resolve_service_paths, + saved_service_config, + write_token_env_file, +) + + +def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str: + return secrets.token_urlsafe(num_bytes) + + +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 write_service_unit( + paths: ServicePaths, *, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH +) -> None: + paths.unit_path.parent.mkdir(parents=True, exist_ok=True) + unit = render_service_unit( + project_root=paths.project_root, + env_file=paths.env_path, + host=host, + port=port, + path=path, + uv_path=find_uv_path(), + ) + paths.unit_path.write_text(unit, encoding="utf-8") + + +def detect_remote_hosts() -> list[str]: + hosts: list[str] = [] + try: + result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False) + except OSError: + result = None + if result and result.returncode == 0: + hosts.extend(result.stdout.split()) + try: + for info in socket.getaddrinfo(socket.gethostname(), None): + hosts.append(info[4][0]) + except OSError: + pass + cleaned: list[str] = [] + for host in hosts: + normalized = host.strip() + if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"): + continue + if normalized not in cleaned: + cleaned.append(normalized) + return cleaned + + +def render_remote_config( + *, + hosts: Sequence[str], + token: str, + port: int = DEFAULT_PORT, + path: str = DEFAULT_PATH, + service_status: str | None = None, +) -> str: + host = hosts[0] if hosts else "127.0.0.1" + url = f"http://{host}:{port}{path}" + alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:]) + alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else "" + status_section = f"Service status: {service_status}\n\n" if service_status else "" + return ( + "Saved MCP client config.\n" + "\n" + f"{status_section}" + "Use this loopback URL on this machine:\n" + f"{url}\n" + f"{alternatives_section}" + "\n" + "Use this header:\n" + f"Authorization: Bearer {token}\n" + "\n" + "Example MCP client config:\n" + "{\n" + ' "mcpServers": {\n' + ' "nocobase-crm": {\n' + ' "type": "streamable-http",\n' + f' "url": "{url}",\n' + ' "headers": {\n' + f' "Authorization": "Bearer {token}"\n' + " }\n" + " }\n" + " }\n" + "}\n" + ) + + +def show_config(project_root: Path) -> str: + config = saved_service_config(project_root) + display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host + return render_remote_config( + hosts=[display_host], + token=config.token, + port=config.port, + path=config.path, + service_status=service_state(), + ) + + +def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(["systemctl", "--user", *args], text=True, check=check) + + +def service_state() -> str: + result = subprocess.run( + ["systemctl", "--user", "is-active", SERVICE_NAME], + capture_output=True, + text=True, + check=False, + ) + state = result.stdout.strip() + if state: + return state + if result.returncode == 0: + return "active" + return "unknown" + + +def probe_http_service(host: str, port: int, path: str, *, token: str, timeout: float = 2.0) -> dict[str, object]: + url = f"http://{host}:{port}{path}" + request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return {"reachable": True, "status": response.status, "url": url} + except urllib.error.HTTPError as error: + return {"reachable": True, "status": error.code, "url": url} + except OSError as error: + 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 + run_systemctl(["restart", SERVICE_NAME]) + return True + + +def new_token( + *, + project_root: Path, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + path: str = DEFAULT_PATH, + nb_env: str = DEFAULT_NB_ENV, + config_path: str = DEFAULT_CONFIG_PATH, + token_bytes: int = DEFAULT_TOKEN_BYTES, +) -> 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, + nb_bin=find_nb_path(), + ) + restart_service_if_installed(paths) + display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host + return render_remote_config(hosts=[display_host], token=token, port=port, path=path, service_status=service_state()) + + +def start_service(*, project_root: Path) -> str: + paths = resolve_service_paths(project_root) + nb_bin = find_nb_path() + if not paths.env_path.exists(): + write_token_env_file( + paths.env_path, + generate_token(), + host=DEFAULT_HOST, + port=DEFAULT_PORT, + path=DEFAULT_PATH, + nb_env=DEFAULT_NB_ENV, + config_path=DEFAULT_CONFIG_PATH, + nb_bin=nb_bin, + ) + config = saved_service_config(project_root) + write_token_env_file( + paths.env_path, + config.token, + host=config.host, + port=config.port, + path=config.path, + nb_env=config.nb_env, + config_path=config.config_path, + nb_bin=nb_bin, + ) + write_service_unit(paths, host=config.host, port=config.port, path=config.path) + run_systemctl(["daemon-reload"]) + run_systemctl(["enable", SERVICE_NAME]) + run_systemctl(["restart", SERVICE_NAME]) + try: + 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 = wait_for_http_service("127.0.0.1", config.port, config.path, token=config.token) + if not probe.get("reachable"): + raise RuntimeError(f"{SERVICE_NAME} is active but HTTP probe failed: {probe.get('error')}") + display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host + return render_remote_config( + hosts=[display_host], + token=config.token, + port=config.port, + path=config.path, + service_status="active", + ) + + +def stop_service() -> None: + run_systemctl(["stop", SERVICE_NAME]) + + +def uninstall_service(project_root: Path) -> None: + paths = resolve_service_paths(project_root) + run_systemctl(["disable", "--now", SERVICE_NAME], check=False) + if paths.unit_path.exists(): + paths.unit_path.unlink() + if paths.env_path.exists(): + paths.env_path.unlink() + try: + paths.env_path.parent.rmdir() + except OSError: + pass + run_systemctl(["daemon-reload"], check=False) + run_systemctl(["reset-failed", SERVICE_NAME], check=False) + + +def service_status(*, project_root: Path) -> str: + paths = resolve_service_paths(project_root) + state = service_state() + lines = [ + f"Service status: {state}", + f"Unit file: {paths.unit_path}", + f"Env file: {paths.env_path}", + ] + if paths.env_path.exists(): + values = read_service_env_file(paths.env_path) + config = saved_service_config(project_root) + display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host + lines.append(f"MCP URL: http://{display_host}:{config.port}{config.path}") + lines.append(f"nb binary: {config.nb_bin}") + lines.append(f"NocoBase CLI env: {config.nb_env} (personal API Key managed by the Agent installer)") + uploads_enabled = values.get(ENABLE_UPLOADS_ENV) == "1" + lines.append(f"Attachment uploads: {'enabled' if uploads_enabled else 'disabled (safe default)'}") + canonical_configured = bool(values.get(API_BASE_URL_ENV) and values.get(API_TOKEN_ENV)) + legacy_configured = bool(values.get(LEGACY_API_BASE_URL_ENV) and values.get(LEGACY_API_TOKEN_ENV)) + if canonical_configured: + credential_state = "configured (canonical)" + elif legacy_configured: + credential_state = "configured (legacy)" + elif any( + values.get(name) + for name in (API_BASE_URL_ENV, API_TOKEN_ENV, LEGACY_API_BASE_URL_ENV, LEGACY_API_TOKEN_ENV) + ): + credential_state = "incomplete" + else: + credential_state = "not configured; using the local nb CLI login" + lines.append(f"Legacy NocoBase credential override: {credential_state}") + probe = probe_http_service("127.0.0.1", config.port, config.path, token=config.token) + if probe.get("reachable"): + lines.append(f"HTTP probe: reachable (status {probe.get('status')})") + else: + lines.append(f"HTTP probe: not reachable ({probe.get('error')})") + else: + lines.append("MCP URL: unavailable until first start creates the env file") + lines.append("HTTP probe: skipped") + return "\n".join(lines) + "\n" + + +__all__ = [ + "generate_token", + "find_nb_path", + "write_service_unit", + "detect_remote_hosts", + "render_remote_config", + "show_config", + "run_systemctl", + "service_state", + "probe_http_service", + "wait_for_http_service", + "restart_service_if_installed", + "new_token", + "start_service", + "stop_service", + "uninstall_service", + "service_status", +] diff --git a/tests/test_config_filters.py b/tests/test_config_filters.py index 734d70e..757f19c 100644 --- a/tests/test_config_filters.py +++ b/tests/test_config_filters.py @@ -46,6 +46,17 @@ class ConfigTests(unittest.TestCase): self.assertEqual(config.project.name_fields[0], "f_project_name") self.assertEqual(config.project.customer_relation_field, "f_projects_client") + def test_default_collection_lists_are_not_shared_between_configs(self) -> None: + with patch.dict(os.environ, {}, clear=True): + first = load_config(None) + second = load_config(None) + + assert first.customer.fields is not None + assert second.customer.fields is not None + first.customer.fields.append("local-only") + + self.assertNotIn("local-only", second.customer.fields) + def test_load_config_reads_label_and_detail_field_overrides(self) -> None: with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / "crm.json" diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index e972407..fbb4f6c 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -12,6 +12,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) import manage_mcp_service +import mcp_service_runtime class McpServiceConfigTests(unittest.TestCase): @@ -106,7 +107,7 @@ class McpServiceConfigTests(unittest.TestCase): with ( patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "nb-custom"}), patch.object( - manage_mcp_service.shutil, + mcp_service_runtime.shutil, "which", return_value="/usr/local/bin/nb-custom", ), @@ -115,13 +116,13 @@ class McpServiceConfigTests(unittest.TestCase): with ( patch.dict("os.environ", {}, clear=True), - patch.object(manage_mcp_service.shutil, "which", return_value="/usr/local/bin/nb"), + patch.object(mcp_service_runtime.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), + patch.object(mcp_service_runtime.shutil, "which", return_value=None), ): with self.assertRaisesRegex(RuntimeError, "Could not find nb"): manage_mcp_service.find_nb_path() @@ -140,6 +141,30 @@ class McpServiceConfigTests(unittest.TestCase): self.assertNotIn("NOCOBASE_MCP_UPLOAD_JWT", content) self.assertNotIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL", content) + def test_saved_service_config_returns_named_typed_values(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + project_root = Path(tmp) + manage_mcp_service.write_token_env_file( + project_root / ".runtime" / "nocobase-crm-mcp.env", + "token", + host="127.0.0.1", + port=10088, + path="/custom-mcp", + nb_env="personal-env", + config_path="custom.json", + nb_bin="/opt/bin/nb", + ) + + config = manage_mcp_service.saved_service_config(project_root) + + self.assertIsInstance(config, manage_mcp_service.ServiceConfig) + self.assertEqual(config.token, "token") + self.assertEqual(config.port, 10088) + self.assertEqual(config.path, "/custom-mcp") + self.assertEqual(config.nb_env, "personal-env") + self.assertEqual(config.config_path, "custom.json") + self.assertEqual(config.nb_bin, "/opt/bin/nb") + def test_token_rotation_preserves_operator_credentials(self) -> None: with tempfile.TemporaryDirectory() as tmp: env_path = Path(tmp) / "mcp.env" @@ -199,10 +224,10 @@ class McpServiceConfigTests(unittest.TestCase): config_home = Path(tmp) / "config" with ( 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, + patch.object(mcp_service_runtime, "generate_token", return_value="new-token"), + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]), + patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")), + patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl, ): output = manage_mcp_service.new_token(project_root=project_root) @@ -229,10 +254,10 @@ class McpServiceConfigTests(unittest.TestCase): with ( 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=[]), - 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(mcp_service_runtime, "generate_token", return_value="new-token"), + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]), + patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")), + patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl, ): manage_mcp_service.new_token(project_root=project_root) @@ -261,12 +286,12 @@ class McpServiceConfigTests(unittest.TestCase): ) 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") as run_systemctl, + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]), + patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")), + patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")), + patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl, patch.object( - manage_mcp_service, + mcp_service_runtime, "probe_http_service", return_value={"reachable": True, "status": 401}, create=True, @@ -299,13 +324,13 @@ class McpServiceConfigTests(unittest.TestCase): config_home = Path(tmp) / "config" with ( patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), - 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(mcp_service_runtime, "generate_token", return_value="first-token"), + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]), + patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")), + patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")), + patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl, patch.object( - manage_mcp_service, + mcp_service_runtime, "probe_http_service", return_value={"reachable": True, "status": 401}, create=True, @@ -340,13 +365,13 @@ class McpServiceConfigTests(unittest.TestCase): ) 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(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]), + patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")), + patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")), + patch.object(mcp_service_runtime, "run_systemctl"), + patch.object(mcp_service_runtime.time, "sleep") as sleep, patch.object( - manage_mcp_service, + mcp_service_runtime, "probe_http_service", side_effect=[ {"reachable": False, "error": "connection refused"}, @@ -378,9 +403,9 @@ 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), + patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")), + patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")), + patch.object(mcp_service_runtime, "run_systemctl", side_effect=fake_systemctl), ): with self.assertRaisesRegex(RuntimeError, "did not become active"): manage_mcp_service.start_service(project_root=project_root) @@ -394,8 +419,8 @@ class McpServiceConfigTests(unittest.TestCase): "saved-token", ) with ( - patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]), - patch.object(manage_mcp_service, "service_state", return_value="inactive", create=True), + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]), + patch.object(mcp_service_runtime, "service_state", return_value="inactive", create=True), ): output = manage_mcp_service.show_config(project_root) @@ -414,10 +439,10 @@ class McpServiceConfigTests(unittest.TestCase): path="/mcp", ) with ( - patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]), - patch.object(manage_mcp_service, "service_state", return_value="active", create=True), + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]), + patch.object(mcp_service_runtime, "service_state", return_value="active", create=True), patch.object( - manage_mcp_service, + mcp_service_runtime, "probe_http_service", return_value={"reachable": True, "status": 401}, create=True, @@ -444,10 +469,10 @@ class McpServiceConfigTests(unittest.TestCase): env_file.write("NOCOBASE_MCP_API_TOKEN=never-print-this\n") with ( - patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]), - patch.object(manage_mcp_service, "service_state", return_value="inactive"), + patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]), + patch.object(mcp_service_runtime, "service_state", return_value="inactive"), patch.object( - manage_mcp_service, + mcp_service_runtime, "probe_http_service", return_value={"reachable": False, "error": "stopped"}, ),