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.
|
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)
|
## Local HTTP MCP (optional)
|
||||||
|
|
||||||
HTTP mode uses streamable HTTP with a separate local bearer token for MCP access:
|
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 json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, fields, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -30,9 +30,20 @@ CUSTOMER_DETAIL_FIELDS = {
|
|||||||
class CustomerConfig:
|
class CustomerConfig:
|
||||||
resource: str = "customers"
|
resource: str = "customers"
|
||||||
id_field: str = "id"
|
id_field: str = "id"
|
||||||
fields: list[str] | None = None
|
fields: list[str] | None = field(
|
||||||
search_fields: list[str] | None = None
|
default_factory=lambda: ["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"]
|
||||||
name_fields: list[str] | None = None
|
)
|
||||||
|
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"
|
english_name_field: str = "f_kh_english_name"
|
||||||
territorial_name_field: str = "f_territorial_name"
|
territorial_name_field: str = "f_territorial_name"
|
||||||
status_field: str = "status"
|
status_field: str = "status"
|
||||||
@@ -40,61 +51,43 @@ class CustomerConfig:
|
|||||||
owner_field: str = "owner"
|
owner_field: str = "owner"
|
||||||
manager_field: str = "kehu_manage_person"
|
manager_field: str = "kehu_manage_person"
|
||||||
detail_fields: dict[str, str] = field(default_factory=lambda: CUSTOMER_DETAIL_FIELDS.copy())
|
detail_fields: dict[str, str] = field(default_factory=lambda: CUSTOMER_DETAIL_FIELDS.copy())
|
||||||
sort: list[str] | None = None
|
sort: list[str] | None = field(default_factory=lambda: ["-updatedAt"])
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ContactConfig:
|
class ContactConfig:
|
||||||
resource: str = "contacts"
|
resource: str = "contacts"
|
||||||
id_field: str = "id"
|
id_field: str = "id"
|
||||||
fields: list[str] | None = None
|
fields: list[str] | None = field(
|
||||||
search_fields: list[str] | None = None
|
default_factory=lambda: ["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"]
|
||||||
name_fields: list[str] | None = None
|
)
|
||||||
|
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"
|
title_field: str = "f_tn8zdm2f938"
|
||||||
phone_field: str = "khlxr_phone_number"
|
phone_field: str = "khlxr_phone_number"
|
||||||
email_field: str = "khlxr_email"
|
email_field: str = "khlxr_email"
|
||||||
customer_field: str = "customer"
|
customer_field: str = "customer"
|
||||||
sort: list[str] | None = None
|
sort: list[str] | None = field(default_factory=lambda: ["-updatedAt"])
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FollowupConfig:
|
class FollowupConfig:
|
||||||
resource: str = "followups"
|
resource: str = "followups"
|
||||||
id_field: str = "id"
|
id_field: str = "id"
|
||||||
fields: list[str] | None = None
|
fields: list[str] | None = field(
|
||||||
search_fields: list[str] | None = None
|
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"
|
customer_field: str = "customer"
|
||||||
contact_field: str = "contact"
|
contact_field: str = "contact"
|
||||||
owner_field: str = "owner"
|
owner_field: str = "owner"
|
||||||
@@ -105,52 +98,26 @@ class FollowupConfig:
|
|||||||
status_field: str = "f_hskl7gjvos9"
|
status_field: str = "f_hskl7gjvos9"
|
||||||
project_field: str = "f_gjjl_projects"
|
project_field: str = "f_gjjl_projects"
|
||||||
location_field: str = "f_b5c4hfi5og6"
|
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:
|
def __post_init__(self) -> None:
|
||||||
if self.owner_fields is None:
|
if self.owner_fields is None:
|
||||||
object.__setattr__(self, "owner_fields", [self.owner_field] if self.owner_field else [])
|
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)
|
@dataclass(frozen=True)
|
||||||
class UserConfig:
|
class UserConfig:
|
||||||
resource: str = "users"
|
resource: str = "users"
|
||||||
id_field: str = "id"
|
id_field: str = "id"
|
||||||
fields: list[str] | None = None
|
fields: list[str] | None = field(
|
||||||
match_fields: list[str] | None = None
|
default_factory=lambda: ["id", "username", "nickname", "email", "f_yh_english_name"]
|
||||||
label_fields: list[str] | None = None
|
)
|
||||||
|
match_fields: list[str] | None = field(
|
||||||
def __post_init__(self) -> None:
|
default_factory=lambda: ["username", "nickname", "email", "f_yh_english_name"]
|
||||||
if self.fields is None:
|
)
|
||||||
object.__setattr__(
|
label_fields: list[str] | None = field(
|
||||||
self,
|
default_factory=lambda: ["nickname", "f_yh_english_name", "username", "email"]
|
||||||
"fields",
|
|
||||||
["id", "username", "nickname", "email", "f_yh_english_name"],
|
|
||||||
)
|
)
|
||||||
if self.match_fields is None:
|
|
||||||
object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"])
|
|
||||||
if self.label_fields is None:
|
|
||||||
object.__setattr__(self, "label_fields", ["nickname", "f_yh_english_name", "username", "email"])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -199,12 +166,12 @@ class CrmConfig:
|
|||||||
env_name: str = "nb-mcp"
|
env_name: str = "nb-mcp"
|
||||||
data_source: str = "main"
|
data_source: str = "main"
|
||||||
timezone: str = "Asia/Shanghai"
|
timezone: str = "Asia/Shanghai"
|
||||||
customer: CustomerConfig = CustomerConfig()
|
customer: CustomerConfig = field(default_factory=CustomerConfig)
|
||||||
contact: ContactConfig = ContactConfig()
|
contact: ContactConfig = field(default_factory=ContactConfig)
|
||||||
followup: FollowupConfig = FollowupConfig()
|
followup: FollowupConfig = field(default_factory=FollowupConfig)
|
||||||
user: UserConfig = UserConfig()
|
user: UserConfig = field(default_factory=UserConfig)
|
||||||
team: TeamConfig = TeamConfig()
|
team: TeamConfig = field(default_factory=TeamConfig)
|
||||||
project: ProjectConfig = ProjectConfig()
|
project: ProjectConfig = field(default_factory=ProjectConfig)
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict[str, Any]:
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
@@ -238,144 +205,76 @@ def _pick(data: dict[str, Any], *names: str) -> Any:
|
|||||||
return None
|
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:
|
if not data:
|
||||||
return CustomerConfig()
|
return base
|
||||||
base = CustomerConfig()
|
return replace(base, **_section_overrides(base, data)) # type: ignore[type-var]
|
||||||
return replace(
|
|
||||||
base,
|
|
||||||
resource=str(_pick(data, "resource") or base.resource),
|
def _customer_config(data: dict[str, Any] | None) -> CustomerConfig:
|
||||||
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
|
return _simple_section(CustomerConfig, data)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _contact_config(data: dict[str, Any] | None) -> ContactConfig:
|
def _contact_config(data: dict[str, Any] | None) -> ContactConfig:
|
||||||
if not data:
|
return _simple_section(ContactConfig, 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _followup_config(data: dict[str, Any] | None) -> FollowupConfig:
|
def _followup_config(data: dict[str, Any] | None) -> FollowupConfig:
|
||||||
if not data:
|
if not data:
|
||||||
return FollowupConfig()
|
return FollowupConfig()
|
||||||
base = FollowupConfig()
|
base = FollowupConfig()
|
||||||
owner_field = str(_pick(data, "ownerField", "owner_field") or base.owner_field)
|
overrides = _section_overrides(base, data, exclude={"owner_fields"})
|
||||||
owner_fields = _string_list(_pick(data, "ownerFields", "owner_fields")) or ([owner_field] if owner_field else [])
|
owner_field = str(overrides.get("owner_field", base.owner_field))
|
||||||
return replace(
|
overrides["owner_fields"] = _string_list(_pick(data, "ownerFields", "owner_fields")) or (
|
||||||
base,
|
[owner_field] if owner_field else []
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
return replace(base, **overrides)
|
||||||
|
|
||||||
|
|
||||||
def _user_config(data: dict[str, Any] | None) -> UserConfig:
|
def _user_config(data: dict[str, Any] | None) -> UserConfig:
|
||||||
if not data:
|
return _simple_section(UserConfig, 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _team_config(data: dict[str, Any] | None) -> TeamConfig:
|
def _team_config(data: dict[str, Any] | None) -> TeamConfig:
|
||||||
if not data:
|
return _simple_section(TeamConfig, 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
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _project_config(data: dict[str, Any] | None) -> ProjectConfig:
|
def _project_config(data: dict[str, Any] | None) -> ProjectConfig:
|
||||||
if not data:
|
if not data:
|
||||||
return ProjectConfig()
|
return ProjectConfig()
|
||||||
base = ProjectConfig()
|
base = ProjectConfig()
|
||||||
return replace(
|
overrides = _section_overrides(base, data, exclude={"name_fields", "progress_values"})
|
||||||
base,
|
# Keep this as None when unset so __post_init__ derives it from a custom name_field.
|
||||||
resource=str(_pick(data, "resource") or base.resource),
|
overrides["name_fields"] = _string_list(_pick(data, "nameFields", "name_fields"))
|
||||||
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
|
return replace(base, **overrides)
|
||||||
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
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | Path | None = None) -> CrmConfig:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hmac
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.server.fastmcp.server import TransportSecuritySettings
|
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 .config import load_config
|
||||||
from .crm import CrmService
|
from .crm import CrmService
|
||||||
from .nb_client import NbAuthenticationError, NbClient, NbCommandError
|
from .nb_client import NbClient
|
||||||
from .nocobase_http import NocoBaseHttpClient
|
from .nocobase_http import NocoBaseHttpClient
|
||||||
from .projects import ProjectService
|
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
|
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_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
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
def _bearer_token(authorization: str) -> str:
|
"BearerTokenAuthMiddleware",
|
||||||
scheme, _, token = authorization.partition(" ")
|
"DEFAULT_HTTP_HOST",
|
||||||
if scheme.lower() != "bearer" or not token:
|
"DEFAULT_HTTP_PATH",
|
||||||
return ""
|
"DEFAULT_HTTP_PORT",
|
||||||
return token.strip()
|
"FastMCP",
|
||||||
|
"_make_write_services",
|
||||||
|
"_read_nocobase_environment",
|
||||||
def _authorization_header(scope: dict[str, Any]) -> str:
|
"create_http_app",
|
||||||
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
"create_server",
|
||||||
return headers.get(b"authorization", b"").decode("latin1")
|
"run_http_server",
|
||||||
|
]
|
||||||
|
|
||||||
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 _make_service(config_path: str | Path | None = None) -> CrmService:
|
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
|
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(
|
def create_server(
|
||||||
*,
|
*,
|
||||||
crm_service: CrmService | None = None,
|
crm_service: CrmService | None = None,
|
||||||
@@ -195,6 +94,7 @@ def create_server(
|
|||||||
service = crm_service or _make_service(config_path)
|
service = crm_service or _make_service(config_path)
|
||||||
if not readonly and project_service is None:
|
if not readonly and project_service is None:
|
||||||
raise ValueError("project_service is required when readonly is false")
|
raise ValueError("project_service is required when readonly is false")
|
||||||
|
|
||||||
transport_security = None
|
transport_security = None
|
||||||
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
|
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
|
||||||
transport_security = TransportSecuritySettings(
|
transport_security = TransportSecuritySettings(
|
||||||
@@ -209,488 +109,19 @@ def create_server(
|
|||||||
transport_security=transport_security,
|
transport_security=transport_security,
|
||||||
)
|
)
|
||||||
|
|
||||||
@server.tool()
|
register_core_tools(
|
||||||
def crm_health() -> dict[str, Any]:
|
server,
|
||||||
"""Check NocoBase CLI reachability and the configured CRM collection mapping."""
|
service,
|
||||||
return _safe_tool_result(service.crm_health)
|
_safe_tool_result,
|
||||||
|
enable_config_tools=os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1",
|
||||||
@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_record_tools(server, service, _safe_tool_result, _safe_raw_tool_result)
|
||||||
|
register_project_tools(server, service, _safe_tool_result, _safe_raw_tool_result)
|
||||||
@server.tool()
|
|
||||||
def resolve_team(
|
|
||||||
query: Annotated[str, Field(description="Team name to resolve.")],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Resolve a team name before using it as a CRM filter."""
|
|
||||||
return _safe_tool_result(lambda: service.resolve_team(query))
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def get_team(
|
|
||||||
team_id: Annotated[Any, Field(description="NocoBase team ID.")],
|
|
||||||
include_members: Annotated[bool, Field(description="Include team members.")] = True,
|
|
||||||
formal_members_only: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(description="Return only formal employees when members are included."),
|
|
||||||
] = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get one CRM team, its managers, and optionally its members."""
|
|
||||||
return _safe_tool_result(
|
|
||||||
lambda: service.get_team(
|
|
||||||
team_id,
|
|
||||||
include_members=include_members,
|
|
||||||
formal_members_only=formal_members_only,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def team_crm_overview(
|
|
||||||
team: Annotated[str, Field(description="Exact or uniquely matching CRM team name.")],
|
|
||||||
date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None,
|
|
||||||
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
|
|
||||||
include_member_breakdown: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(description="Include per-member customer ownership and follow-up contribution."),
|
|
||||||
] = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return team totals and member contributions using formal CRM team membership."""
|
|
||||||
return _safe_tool_result(
|
|
||||||
lambda: service.team_crm_overview(
|
|
||||||
team,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
include_member_breakdown=include_member_breakdown,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def crm_config_template() -> dict[str, Any]:
|
|
||||||
"""Infer a config.json template from collection field metadata without reading business records."""
|
|
||||||
return _safe_tool_result(service.crm_config_template)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def list_customers(
|
|
||||||
query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None,
|
|
||||||
status: Annotated[str | None, Field(description="Optional exact customer status.")] = None,
|
|
||||||
owner: Annotated[
|
|
||||||
str | None, Field(description="Optional customer owner name, username, email, or English name.")
|
|
||||||
] = None,
|
|
||||||
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
|
|
||||||
source: Annotated[str | None, Field(description="Optional exact customer source.")] = None,
|
|
||||||
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
|
|
||||||
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
|
|
||||||
include_raw: Annotated[
|
|
||||||
bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")
|
|
||||||
] = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""List CRM customers with compact rows and pagination metadata.
|
|
||||||
|
|
||||||
Rows are {id, name, english_name, territorial_name, status, source,
|
|
||||||
owners: [{id, name}], managers, createdAt, updatedAt}. Empty values are
|
|
||||||
omitted. A name shown as "#123" means that customer has no name recorded.
|
|
||||||
"""
|
|
||||||
return _safe_raw_tool_result(
|
|
||||||
include_raw,
|
|
||||||
lambda: service.list_customers(
|
|
||||||
query=query,
|
|
||||||
status=status,
|
|
||||||
owner=owner,
|
|
||||||
team=team,
|
|
||||||
source=source,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_raw=include_raw,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def get_customer(
|
|
||||||
customer_id: Annotated[Any, Field(description="Customer primary key value.")],
|
|
||||||
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
|
|
||||||
include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = True,
|
|
||||||
followup_limit: Annotated[int, Field(description="Maximum related follow-ups to return.")] = 10,
|
|
||||||
include_raw: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(description="When true, include original NocoBase rows under raw. Defaults to false."),
|
|
||||||
] = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get one customer with its contacts and recent follow-ups.
|
|
||||||
|
|
||||||
Returns {customer, contacts, recent_followups, timezone}. The customer is
|
|
||||||
{id, name, english_name, territorial_name, status, source, owners, managers,
|
|
||||||
address, intro, tags, ...} using readable keys, never NocoBase field codes.
|
|
||||||
Contacts are {id, name, title} without phone or email -- call list_contacts
|
|
||||||
for those. Follow-ups use the same row shape as list_followups. Empty values
|
|
||||||
are omitted, so a missing key means empty rather than unknown.
|
|
||||||
"""
|
|
||||||
return _safe_raw_tool_result(
|
|
||||||
include_raw,
|
|
||||||
lambda: service.get_customer(
|
|
||||||
customer_id,
|
|
||||||
include_contacts=include_contacts,
|
|
||||||
include_recent_followups=include_recent_followups,
|
|
||||||
followup_limit=followup_limit,
|
|
||||||
include_raw=include_raw,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def list_contacts(
|
|
||||||
customer_id: Annotated[
|
|
||||||
Any | None, Field(description="Optional customer primary key for relation filtering.")
|
|
||||||
] = None,
|
|
||||||
query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None,
|
|
||||||
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
|
|
||||||
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
|
|
||||||
include_details: Annotated[
|
|
||||||
bool,
|
|
||||||
Field(description="Include contact phone and email. Defaults to true."),
|
|
||||||
] = True,
|
|
||||||
include_raw: Annotated[
|
|
||||||
bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")
|
|
||||||
] = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""List CRM contacts as compact rows, optionally filtered by customer.
|
|
||||||
|
|
||||||
Rows are {id, name, title, phone, email, customers: [{id, name}],
|
|
||||||
createdAt, updatedAt}. Set include_details=false to omit phone and email.
|
|
||||||
Empty values are omitted.
|
|
||||||
"""
|
|
||||||
return _safe_raw_tool_result(
|
|
||||||
include_raw,
|
|
||||||
lambda: service.list_contacts(
|
|
||||||
customer_id=customer_id,
|
|
||||||
query=query,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_details=include_details,
|
|
||||||
include_raw=include_raw,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def list_followups(
|
|
||||||
customer_id: Annotated[
|
|
||||||
Any | None, Field(description="Optional customer primary key for relation filtering.")
|
|
||||||
] = None,
|
|
||||||
contact_id: Annotated[
|
|
||||||
Any | None, Field(description="Optional contact primary key for relation filtering.")
|
|
||||||
] = None,
|
|
||||||
owner: Annotated[
|
|
||||||
str | None, Field(description="Optional follow-up participant name, username, email, or English name.")
|
|
||||||
] = None,
|
|
||||||
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
|
|
||||||
project_id: Annotated[Any | None, Field(description="Optional CRM project ID.")] = None,
|
|
||||||
followup_type: Annotated[str | None, Field(description="Optional exact follow-up type value.")] = None,
|
|
||||||
record_status: Annotated[str | None, Field(description="Optional exact follow-up record status value.")] = None,
|
|
||||||
date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None,
|
|
||||||
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
|
|
||||||
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
|
|
||||||
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
|
|
||||||
include_raw: Annotated[
|
|
||||||
bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")
|
|
||||||
] = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""List customer follow-up records as compact rows with pagination metadata.
|
|
||||||
|
|
||||||
Rows are {id, followedAt, customers: [{id, name}], contacts, participants,
|
|
||||||
projects, type, status, location, content}. Content is the full follow-up
|
|
||||||
text, never truncated. type and status are the display labels; use
|
|
||||||
crm_filter_options for the codes the followup_type and record_status
|
|
||||||
filters expect. creator appears only when the submitter is not also a
|
|
||||||
participant. Empty values are omitted.
|
|
||||||
"""
|
|
||||||
return _safe_raw_tool_result(
|
|
||||||
include_raw,
|
|
||||||
lambda: service.list_followups(
|
|
||||||
customer_id=customer_id,
|
|
||||||
contact_id=contact_id,
|
|
||||||
owner=owner,
|
|
||||||
team=team,
|
|
||||||
project_id=project_id,
|
|
||||||
followup_type=followup_type,
|
|
||||||
record_status=record_status,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_raw=include_raw,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def list_projects(
|
|
||||||
customer_id: Annotated[Any | None, Field(description="Optional customer ID.")] = None,
|
|
||||||
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
|
|
||||||
stage: Annotated[str | None, Field(description="Optional project stage label or configured value.")] = None,
|
|
||||||
limit: Annotated[int, Field(description="Maximum projects to return.")] = 20,
|
|
||||||
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
|
|
||||||
include_raw: Annotated[bool, Field(description="Include original NocoBase project rows.")] = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""List CRM projects linked to customers, teams, and project stages."""
|
|
||||||
return _safe_raw_tool_result(
|
|
||||||
include_raw,
|
|
||||||
lambda: service.list_projects(
|
|
||||||
customer_id=customer_id,
|
|
||||||
team=team,
|
|
||||||
stage=stage,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_raw=include_raw,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def get_project(
|
|
||||||
project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")],
|
|
||||||
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
|
|
||||||
include_followups: Annotated[bool, Field(description="Include related follow-ups.")] = True,
|
|
||||||
include_updates: Annotated[bool, Field(description="Include project update records.")] = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get a CRM project with its existing NocoBase relations."""
|
|
||||||
return _safe_tool_result(
|
|
||||||
lambda: service.get_project(
|
|
||||||
project_id,
|
|
||||||
include_contacts=include_contacts,
|
|
||||||
include_followups=include_followups,
|
|
||||||
include_updates=include_updates,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def get_project_timeline(
|
|
||||||
project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")],
|
|
||||||
date_from: Annotated[str | None, Field(description="Optional lower event date bound.")] = None,
|
|
||||||
date_to: Annotated[str | None, Field(description="Optional upper event date bound.")] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return a chronological CRM project timeline from follow-ups and project updates."""
|
|
||||||
return _safe_tool_result(lambda: service.get_project_timeline(project_id, date_from=date_from, date_to=date_to))
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def list_stale_customers(
|
|
||||||
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
|
|
||||||
owner: Annotated[str | None, Field(description="Optional customer owner.")] = None,
|
|
||||||
inactive_days: Annotated[int, Field(description="Days without a valid follow-up.")] = 30,
|
|
||||||
as_of: Annotated[str | None, Field(description="Reference date in YYYY-MM-DD format.")] = None,
|
|
||||||
limit: Annotated[int, Field(description="Maximum customers to return.")] = 20,
|
|
||||||
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""List customers whose last valid follow-up is older than the configured threshold."""
|
|
||||||
return _safe_tool_result(
|
|
||||||
lambda: service.list_stale_customers(
|
|
||||||
team=team,
|
|
||||||
owner=owner,
|
|
||||||
inactive_days=inactive_days,
|
|
||||||
as_of=as_of,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not readonly:
|
if not readonly:
|
||||||
|
register_write_tools(server, project_service, _safe_tool_result)
|
||||||
@server.tool()
|
|
||||||
def create_project(
|
|
||||||
name: Annotated[str, Field(description="Project name. It must not be blank.")],
|
|
||||||
customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None,
|
|
||||||
inquiry_date: Annotated[
|
|
||||||
str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.")
|
|
||||||
] = None,
|
|
||||||
progress: Annotated[
|
|
||||||
str | None, Field(description="Optional configured Chinese project progress label.")
|
|
||||||
] = None,
|
|
||||||
applications: Annotated[str | None, Field(description="Optional project applications.")] = None,
|
|
||||||
part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None,
|
|
||||||
period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None,
|
|
||||||
details: Annotated[str | None, Field(description="Optional project details.")] = None,
|
|
||||||
attachment_ids: Annotated[
|
|
||||||
list[Any] | None, Field(description="Optional uploaded attachment IDs to associate.")
|
|
||||||
] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create one fixed-schema NocoBase project and optionally associate uploaded attachments."""
|
|
||||||
assert project_service is not None
|
|
||||||
return _safe_tool_result(
|
|
||||||
lambda: project_service.create_project(
|
|
||||||
name=name,
|
|
||||||
customer_id=customer_id,
|
|
||||||
inquiry_date=inquiry_date,
|
|
||||||
progress=progress,
|
|
||||||
applications=applications,
|
|
||||||
part_numbers=part_numbers,
|
|
||||||
period_usage=period_usage,
|
|
||||||
details=details,
|
|
||||||
attachment_ids=attachment_ids,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
|
||||||
def update_project(
|
|
||||||
project_id: Annotated[Any, Field(description="NocoBase project ID.")],
|
|
||||||
name: Annotated[str | None, Field(description="Optional project name.")] = None,
|
|
||||||
customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None,
|
|
||||||
inquiry_date: Annotated[
|
|
||||||
str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.")
|
|
||||||
] = None,
|
|
||||||
progress: Annotated[
|
|
||||||
str | None, Field(description="Optional configured Chinese project progress label.")
|
|
||||||
] = None,
|
|
||||||
applications: Annotated[str | None, Field(description="Optional project applications.")] = None,
|
|
||||||
part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None,
|
|
||||||
period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None,
|
|
||||||
details: Annotated[str | None, Field(description="Optional project details.")] = None,
|
|
||||||
add_attachment_ids: Annotated[
|
|
||||||
list[Any] | None, Field(description="Attachment IDs to add incrementally.")
|
|
||||||
] = None,
|
|
||||||
remove_attachment_ids: Annotated[
|
|
||||||
list[Any] | None, Field(description="Attachment IDs to remove incrementally.")
|
|
||||||
] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Update fixed project fields and incrementally add or remove uploaded attachments."""
|
|
||||||
assert project_service is not None
|
|
||||||
return _safe_tool_result(
|
|
||||||
lambda: project_service.update_project(
|
|
||||||
project_id,
|
|
||||||
name=name,
|
|
||||||
customer_id=customer_id,
|
|
||||||
inquiry_date=inquiry_date,
|
|
||||||
progress=progress,
|
|
||||||
applications=applications,
|
|
||||||
part_numbers=part_numbers,
|
|
||||||
period_usage=period_usage,
|
|
||||||
details=details,
|
|
||||||
add_attachment_ids=add_attachment_ids,
|
|
||||||
remove_attachment_ids=remove_attachment_ids,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return server
|
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(
|
def run_http_server(
|
||||||
*,
|
*,
|
||||||
config_path: Path | None,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
from agent_installer.auth import (
|
||||||
import json
|
connect_with_personal_key,
|
||||||
import os
|
normalize_api_base_url,
|
||||||
import re
|
)
|
||||||
import shutil
|
from agent_installer.cli import build_parser, main
|
||||||
import subprocess
|
from agent_installer.common import (
|
||||||
import sys
|
DEFAULT_ENV_NAME,
|
||||||
import tempfile
|
ENV_NAME_PATTERN,
|
||||||
from collections.abc import Callable, Sequence
|
MIN_PYTHON,
|
||||||
from dataclasses import asdict, dataclass
|
REQUIRED_PROJECT_FILES,
|
||||||
from pathlib import Path
|
SERVER_NAME,
|
||||||
from typing import Any
|
Check,
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
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)
|
__all__ = [
|
||||||
DEFAULT_ENV_NAME = "nb-mcp"
|
"Check",
|
||||||
SERVER_NAME = "nocobase-crm"
|
"DEFAULT_ENV_NAME",
|
||||||
REQUIRED_PROJECT_FILES = ("pyproject.toml", "uv.lock", "config.example.json")
|
"ENV_NAME_PATTERN",
|
||||||
ENV_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
"InstallerError",
|
||||||
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
"MIN_PYTHON",
|
||||||
|
"REQUIRED_PROJECT_FILES",
|
||||||
Which = Callable[[str], str | None]
|
"Runner",
|
||||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
"SERVER_NAME",
|
||||||
|
"Which",
|
||||||
|
"build_parser",
|
||||||
class InstallerError(RuntimeError):
|
"connect_with_personal_key",
|
||||||
"""Raised when a deterministic installation step cannot finish safely."""
|
"doctor_project",
|
||||||
|
"inspect_project",
|
||||||
|
"main",
|
||||||
@dataclass(frozen=True)
|
"normalize_api_base_url",
|
||||||
class Check:
|
"prepare_project",
|
||||||
name: str
|
"render_client_config",
|
||||||
ok: bool
|
"set_config_env",
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
from mcp_service_cli import main
|
||||||
import os
|
from mcp_service_config import (
|
||||||
import secrets
|
API_BASE_URL_ENV,
|
||||||
import shlex
|
API_TOKEN_ENV,
|
||||||
import shutil
|
DEFAULT_CONFIG_PATH,
|
||||||
import socket
|
DEFAULT_HOST,
|
||||||
import subprocess
|
DEFAULT_NB_BIN,
|
||||||
import time
|
DEFAULT_NB_ENV,
|
||||||
import urllib.error
|
DEFAULT_PATH,
|
||||||
import urllib.request
|
DEFAULT_PORT,
|
||||||
from collections.abc import Sequence
|
DEFAULT_TOKEN_BYTES,
|
||||||
from dataclasses import dataclass
|
DEFAULT_UPLOAD_MAX_BYTES,
|
||||||
from pathlib import Path
|
DEFAULT_UV_CACHE_DIR,
|
||||||
|
|
||||||
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,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
@dataclass(frozen=True)
|
"SERVICE_NAME",
|
||||||
class ServicePaths:
|
"ENV_RELATIVE_PATH",
|
||||||
project_root: Path
|
"DEFAULT_HOST",
|
||||||
unit_path: Path
|
"DEFAULT_PORT",
|
||||||
env_path: Path
|
"DEFAULT_PATH",
|
||||||
|
"DEFAULT_NB_ENV",
|
||||||
|
"DEFAULT_CONFIG_PATH",
|
||||||
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
|
"DEFAULT_NB_BIN",
|
||||||
return secrets.token_urlsafe(num_bytes)
|
"DEFAULT_TOKEN_BYTES",
|
||||||
|
"DEFAULT_UV_CACHE_DIR",
|
||||||
|
"DEFAULT_UPLOAD_MAX_BYTES",
|
||||||
def systemd_quote(value: str | Path) -> str:
|
"ENABLE_UPLOADS_ENV",
|
||||||
return shlex.quote(str(value))
|
"API_BASE_URL_ENV",
|
||||||
|
"API_TOKEN_ENV",
|
||||||
|
"LEGACY_API_BASE_URL_ENV",
|
||||||
def find_uv_path() -> Path:
|
"LEGACY_API_TOKEN_ENV",
|
||||||
configured = os.environ.get("UV_PATH", "").strip()
|
"MANAGED_ENV_NAMES",
|
||||||
discovered = configured or shutil.which("uv")
|
"ServicePaths",
|
||||||
if not discovered:
|
"ServiceConfig",
|
||||||
raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute path.")
|
"systemd_quote",
|
||||||
return Path(discovered).expanduser().resolve()
|
"find_uv_path",
|
||||||
|
"resolve_service_paths",
|
||||||
|
"render_service_unit",
|
||||||
def find_nb_path() -> Path:
|
"write_token_env_file",
|
||||||
configured = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip()
|
"read_service_env_file",
|
||||||
candidate = configured or "nb"
|
"saved_service_config",
|
||||||
candidate_path = Path(candidate).expanduser()
|
"generate_token",
|
||||||
if candidate_path.is_absolute():
|
"find_nb_path",
|
||||||
if candidate_path.is_file() and os.access(candidate_path, os.X_OK):
|
"write_service_unit",
|
||||||
return Path(os.path.abspath(candidate_path))
|
"detect_remote_hosts",
|
||||||
raise RuntimeError(f"NOCOBASE_MCP_NB_BIN points to {candidate_path}, but it is not an executable file.")
|
"render_remote_config",
|
||||||
discovered = shutil.which(candidate)
|
"show_config",
|
||||||
if not discovered:
|
"run_systemctl",
|
||||||
raise RuntimeError(
|
"service_state",
|
||||||
"Could not find nb on PATH. Install the NocoBase CLI or set NOCOBASE_MCP_NB_BIN to its absolute path."
|
"probe_http_service",
|
||||||
)
|
"wait_for_http_service",
|
||||||
return Path(os.path.abspath(str(Path(discovered).expanduser())))
|
"restart_service_if_installed",
|
||||||
|
"new_token",
|
||||||
|
"start_service",
|
||||||
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths:
|
"stop_service",
|
||||||
root = project_root.expanduser().resolve()
|
"uninstall_service",
|
||||||
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
"service_status",
|
||||||
unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME
|
"main",
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__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.name_fields[0], "f_project_name")
|
||||||
self.assertEqual(config.project.customer_relation_field, "f_projects_client")
|
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:
|
def test_load_config_reads_label_and_detail_field_overrides(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
config_path = Path(tmp) / "crm.json"
|
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))
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
import manage_mcp_service
|
import manage_mcp_service
|
||||||
|
import mcp_service_runtime
|
||||||
|
|
||||||
|
|
||||||
class McpServiceConfigTests(unittest.TestCase):
|
class McpServiceConfigTests(unittest.TestCase):
|
||||||
@@ -106,7 +107,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "nb-custom"}),
|
patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "nb-custom"}),
|
||||||
patch.object(
|
patch.object(
|
||||||
manage_mcp_service.shutil,
|
mcp_service_runtime.shutil,
|
||||||
"which",
|
"which",
|
||||||
return_value="/usr/local/bin/nb-custom",
|
return_value="/usr/local/bin/nb-custom",
|
||||||
),
|
),
|
||||||
@@ -115,13 +116,13 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {}, clear=True),
|
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"))
|
self.assertEqual(manage_mcp_service.find_nb_path(), Path("/usr/local/bin/nb"))
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {}, clear=True),
|
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"):
|
with self.assertRaisesRegex(RuntimeError, "Could not find nb"):
|
||||||
manage_mcp_service.find_nb_path()
|
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_JWT", content)
|
||||||
self.assertNotIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL", 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:
|
def test_token_rotation_preserves_operator_credentials(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
env_path = Path(tmp) / "mcp.env"
|
env_path = Path(tmp) / "mcp.env"
|
||||||
@@ -199,10 +224,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
config_home = Path(tmp) / "config"
|
config_home = Path(tmp) / "config"
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
patch.object(mcp_service_runtime, "generate_token", return_value="new-token"),
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
patch.object(mcp_service_runtime, "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(mcp_service_runtime, "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, "run_systemctl") as run_systemctl,
|
||||||
):
|
):
|
||||||
output = manage_mcp_service.new_token(project_root=project_root)
|
output = manage_mcp_service.new_token(project_root=project_root)
|
||||||
|
|
||||||
@@ -229,10 +254,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
patch.object(mcp_service_runtime, "generate_token", return_value="new-token"),
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
|
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]),
|
||||||
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
|
patch.object(mcp_service_runtime, "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, "run_systemctl") as run_systemctl,
|
||||||
):
|
):
|
||||||
manage_mcp_service.new_token(project_root=project_root)
|
manage_mcp_service.new_token(project_root=project_root)
|
||||||
|
|
||||||
@@ -261,12 +286,12 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
patch.object(mcp_service_runtime, "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(mcp_service_runtime, "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(mcp_service_runtime, "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, "run_systemctl") as run_systemctl,
|
||||||
patch.object(
|
patch.object(
|
||||||
manage_mcp_service,
|
mcp_service_runtime,
|
||||||
"probe_http_service",
|
"probe_http_service",
|
||||||
return_value={"reachable": True, "status": 401},
|
return_value={"reachable": True, "status": 401},
|
||||||
create=True,
|
create=True,
|
||||||
@@ -299,13 +324,13 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
config_home = Path(tmp) / "config"
|
config_home = Path(tmp) / "config"
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||||
patch.object(manage_mcp_service, "generate_token", return_value="first-token"),
|
patch.object(mcp_service_runtime, "generate_token", return_value="first-token"),
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
patch.object(mcp_service_runtime, "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(mcp_service_runtime, "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(mcp_service_runtime, "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, "run_systemctl") as run_systemctl,
|
||||||
patch.object(
|
patch.object(
|
||||||
manage_mcp_service,
|
mcp_service_runtime,
|
||||||
"probe_http_service",
|
"probe_http_service",
|
||||||
return_value={"reachable": True, "status": 401},
|
return_value={"reachable": True, "status": 401},
|
||||||
create=True,
|
create=True,
|
||||||
@@ -340,13 +365,13 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
patch.object(mcp_service_runtime, "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(mcp_service_runtime, "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(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
|
||||||
patch.object(manage_mcp_service, "run_systemctl"),
|
patch.object(mcp_service_runtime, "run_systemctl"),
|
||||||
patch.object(manage_mcp_service.time, "sleep") as sleep,
|
patch.object(mcp_service_runtime.time, "sleep") as sleep,
|
||||||
patch.object(
|
patch.object(
|
||||||
manage_mcp_service,
|
mcp_service_runtime,
|
||||||
"probe_http_service",
|
"probe_http_service",
|
||||||
side_effect=[
|
side_effect=[
|
||||||
{"reachable": False, "error": "connection refused"},
|
{"reachable": False, "error": "connection refused"},
|
||||||
@@ -378,9 +403,9 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
patch.object(mcp_service_runtime, "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(mcp_service_runtime, "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, "run_systemctl", side_effect=fake_systemctl),
|
||||||
):
|
):
|
||||||
with self.assertRaisesRegex(RuntimeError, "did not become active"):
|
with self.assertRaisesRegex(RuntimeError, "did not become active"):
|
||||||
manage_mcp_service.start_service(project_root=project_root)
|
manage_mcp_service.start_service(project_root=project_root)
|
||||||
@@ -394,8 +419,8 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
"saved-token",
|
"saved-token",
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
patch.object(mcp_service_runtime, "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, "service_state", return_value="inactive", create=True),
|
||||||
):
|
):
|
||||||
output = manage_mcp_service.show_config(project_root)
|
output = manage_mcp_service.show_config(project_root)
|
||||||
|
|
||||||
@@ -414,10 +439,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
path="/mcp",
|
path="/mcp",
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
patch.object(mcp_service_runtime, "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, "service_state", return_value="active", create=True),
|
||||||
patch.object(
|
patch.object(
|
||||||
manage_mcp_service,
|
mcp_service_runtime,
|
||||||
"probe_http_service",
|
"probe_http_service",
|
||||||
return_value={"reachable": True, "status": 401},
|
return_value={"reachable": True, "status": 401},
|
||||||
create=True,
|
create=True,
|
||||||
@@ -444,10 +469,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
|||||||
env_file.write("NOCOBASE_MCP_API_TOKEN=never-print-this\n")
|
env_file.write("NOCOBASE_MCP_API_TOKEN=never-print-this\n")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
|
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]),
|
||||||
patch.object(manage_mcp_service, "service_state", return_value="inactive"),
|
patch.object(mcp_service_runtime, "service_state", return_value="inactive"),
|
||||||
patch.object(
|
patch.object(
|
||||||
manage_mcp_service,
|
mcp_service_runtime,
|
||||||
"probe_http_service",
|
"probe_http_service",
|
||||||
return_value={"reachable": False, "error": "stopped"},
|
return_value={"reachable": False, "error": "stopped"},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user