Merge pull request #6 from Donaldttt/refactor/structure-simplification
refactor: simplify CRM MCP architecture
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
+98
-199
@@ -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"],
|
||||
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"]
|
||||
)
|
||||
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"])
|
||||
|
||||
|
||||
@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:
|
||||
|
||||
+23
-2162
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 "<unnamed>"
|
||||
|
||||
|
||||
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
|
||||
+39
-608
@@ -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,488 +109,19 @@ 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,
|
||||
register_core_tools(
|
||||
server,
|
||||
service,
|
||||
_safe_tool_result,
|
||||
enable_config_tools=os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1",
|
||||
)
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
|
||||
register_write_tools(server, project_service, _safe_tool_result)
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def run_http_server(
|
||||
*,
|
||||
config_path: Path | None,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
+43
-583
@@ -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__":
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.",
|
||||
],
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-562
@@ -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",
|
||||
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,
|
||||
"NOCOBASE_MCP_UPLOAD_MAX_BYTES",
|
||||
}
|
||||
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__":
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
+64
-39
@@ -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"},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user