diff --git a/nocobase_crm_mcp/config.py b/nocobase_crm_mcp/config.py index 3eee589..3b7f1c1 100644 --- a/nocobase_crm_mcp/config.py +++ b/nocobase_crm_mcp/config.py @@ -2,11 +2,21 @@ from __future__ import annotations import json import os -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any +PROJECT_PROGRESS_VALUES = { + "询价阶段": "fk9xj8akn46", + "已报价": "k4w2vcz819w", + "送样": "g95vqeetu83", + "已定点": "fe0peg505d7", + "SOP": "a9t9awhtt3r", + "终止": "a9t9awhtsfasg3r", +} + + @dataclass(frozen=True) class CustomerConfig: resource: str = "customers" @@ -109,6 +119,22 @@ class UserConfig: object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"]) +@dataclass(frozen=True) +class ProjectConfig: + resource: str = "t_projects" + id_field: str = "id" + name_field: str = "f_project_name" + customer_field: str = "client_ID" + inquiry_date_field: str = "xm_project_inquiry_date" + progress_field: str = "f_projects_progress" + applications_field: str = "f_projects_applications" + part_numbers_field: str = "f_projects_part_numbers" + period_usage_field: str = "f_projects_period_usage" + details_field: str = "f_projects_details" + attachment_field: str = "f_projects_attachments" + progress_values: dict[str, str] = field(default_factory=lambda: PROJECT_PROGRESS_VALUES.copy()) + + @dataclass(frozen=True) class CrmConfig: env_name: str = "nb-mcp" @@ -117,6 +143,7 @@ class CrmConfig: contact: ContactConfig = ContactConfig() followup: FollowupConfig = FollowupConfig() user: UserConfig = UserConfig() + project: ProjectConfig = ProjectConfig() def _load_json(path: Path) -> dict[str, Any]: @@ -208,6 +235,26 @@ def _user_config(data: dict[str, Any] | None) -> UserConfig: ) +def _project_config(data: dict[str, Any] | None) -> ProjectConfig: + if not data: + return ProjectConfig() + base = ProjectConfig() + return replace( + base, + resource=str(_pick(data, "resource") or base.resource), + id_field=str(_pick(data, "idField", "id_field") or base.id_field), + name_field=str(_pick(data, "nameField", "name_field") or base.name_field), + customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_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), + ) + + def load_config(path: str | Path | None = None) -> CrmConfig: configured_path = path or os.environ.get("NOCOBASE_MCP_CONFIG") raw: dict[str, Any] = {} @@ -228,4 +275,5 @@ def load_config(path: str | Path | None = None) -> CrmConfig: contact=_contact_config(collections.get("contacts")), followup=_followup_config(collections.get("followups")), user=_user_config(raw.get("users") or raw.get("user")), + project=_project_config(raw.get("projects")), ) diff --git a/nocobase_crm_mcp/nocobase_http.py b/nocobase_crm_mcp/nocobase_http.py new file mode 100644 index 0000000..3fc8849 --- /dev/null +++ b/nocobase_crm_mcp/nocobase_http.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Sequence + +import httpx + + +class NocoBaseHttpError(RuntimeError): + def __init__(self, status_code: int, message: str) -> None: + self.status_code = status_code + super().__init__(message) + + +class NocoBaseHttpClient: + """Small synchronous client for the fixed NocoBase attachment endpoints.""" + + _ATTACHMENT_FIELD = "t_projects.f_projects_attachments" + + def __init__( + self, + api_base_url: str, + jwt: str, + timeout: float = 30.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.api_base_url = api_base_url.strip().rstrip("/") + self.jwt = jwt.strip() + if not self.api_base_url: + raise ValueError("NocoBase API base URL must not be blank") + if not self.jwt: + raise ValueError("NocoBase JWT must not be blank") + self._client = httpx.Client( + base_url=self.api_base_url, + headers={"Authorization": f"Bearer {self.jwt}"}, + timeout=timeout, + transport=transport, + ) + + def upload_attachment(self, path: Path, filename: str, mimetype: str | None) -> dict[str, Any]: + with Path(path).open("rb") as handle: + response = self._client.post( + "/attachments:create", + params={"attachmentField": self._ATTACHMENT_FIELD}, + files={"file": (filename, handle, mimetype or "application/octet-stream")}, + ) + payload = self._json_response(response) + data = payload.get("data") + if not isinstance(data, dict): + raise NocoBaseHttpError(response.status_code, "NocoBase response did not contain an attachment object") + return data + + def add_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any]) -> None: + self._post_attachment_ids(project_id, attachment_ids, "add") + + def remove_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any]) -> None: + self._post_attachment_ids(project_id, attachment_ids, "remove") + + def _post_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any], action: str) -> None: + response = self._client.post( + f"/t_projects/{project_id}/f_projects_attachments:{action}", + json={"tk": list(attachment_ids)}, + ) + self._raise_for_error(response) + + def _json_response(self, response: httpx.Response) -> dict[str, Any]: + self._raise_for_error(response) + try: + payload = response.json() + except ValueError as error: + raise NocoBaseHttpError(response.status_code, "NocoBase response was not valid JSON") from error + if not isinstance(payload, dict): + raise NocoBaseHttpError(response.status_code, "NocoBase response was not a JSON object") + return payload + + def _raise_for_error(self, response: httpx.Response) -> None: + if response.is_success: + return + message = response.text.strip() or response.reason_phrase or "NocoBase request failed" + raise NocoBaseHttpError(response.status_code, self._redact(message)) + + def _redact(self, value: str) -> str: + return value.replace(self.jwt, "") diff --git a/pyproject.toml b/pyproject.toml index ed8869c..273d533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,9 @@ version = "0.1.0" description = "Read-only MCP server for querying NocoBase CRM data through nb CLI." requires-python = ">=3.12" dependencies = [ + "httpx>=0.27,<1", "mcp[cli]>=1.27,<2", + "python-multipart>=0.0.9,<1", ] [project.scripts] diff --git a/tests/test_nocobase_http.py b/tests/test_nocobase_http.py new file mode 100644 index 0000000..080ec06 --- /dev/null +++ b/tests/test_nocobase_http.py @@ -0,0 +1,93 @@ +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import httpx + +from nocobase_crm_mcp.config import load_config +from nocobase_crm_mcp.nocobase_http import NocoBaseHttpClient, NocoBaseHttpError + + +class ProjectConfigTests(unittest.TestCase): + def test_load_config_parses_project_defaults(self) -> None: + with TemporaryDirectory() as directory: + path = Path(directory) / "config.json" + path.write_text(json.dumps({"projects": {"resource": "t_projects"}}), encoding="utf-8") + + config = load_config(path) + + self.assertEqual(config.project.resource, "t_projects") + self.assertEqual(config.project.name_field, "f_project_name") + self.assertEqual(config.project.customer_field, "client_ID") + self.assertEqual(config.project.inquiry_date_field, "xm_project_inquiry_date") + self.assertEqual(config.project.progress_field, "f_projects_progress") + self.assertEqual(config.project.applications_field, "f_projects_applications") + self.assertEqual(config.project.part_numbers_field, "f_projects_part_numbers") + self.assertEqual(config.project.period_usage_field, "f_projects_period_usage") + self.assertEqual(config.project.details_field, "f_projects_details") + self.assertEqual(config.project.attachment_field, "f_projects_attachments") + self.assertEqual(config.project.progress_values["SOP"], "a9t9awhtt3r") + + +class NocoBaseHttpClientTests(unittest.TestCase): + def test_upload_posts_file_with_attachment_field(self) -> None: + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["auth"] = request.headers["authorization"] + return httpx.Response(200, json={"data": {"id": 42}}) + + with TemporaryDirectory() as directory: + path = Path(directory) / "quote.pdf" + path.write_bytes(b"pdf") + client = NocoBaseHttpClient( + "https://nb.example/api/", "jwt", transport=httpx.MockTransport(handler) + ) + + self.assertEqual(client.upload_attachment(path, "quote.pdf", "application/pdf")["id"], 42) + + self.assertTrue(seen["url"].endswith("/attachments:create?attachmentField=t_projects.f_projects_attachments")) + self.assertEqual(seen["auth"], "Bearer jwt") + + def test_attachment_associations_post_ids_to_fixed_relation(self) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(204) + + client = NocoBaseHttpClient("https://nb.example/api", "jwt", transport=httpx.MockTransport(handler)) + client.add_attachment_ids(7, [8, 9]) + client.remove_attachment_ids(7, [10]) + + self.assertEqual(str(requests[0].url), "https://nb.example/api/t_projects/7/f_projects_attachments:add") + self.assertEqual(json.loads(requests[0].content), {"tk": [8, 9]}) + self.assertEqual(str(requests[1].url), "https://nb.example/api/t_projects/7/f_projects_attachments:remove") + self.assertEqual(json.loads(requests[1].content), {"tk": [10]}) + + def test_rejects_blank_upload_credentials(self) -> None: + with self.assertRaises(ValueError): + NocoBaseHttpClient(" ", "jwt") + with self.assertRaises(ValueError): + NocoBaseHttpClient("https://nb.example/api", " ") + + def test_http_error_redacts_jwt(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="Bearer super-secret-token is invalid") + + client = NocoBaseHttpClient( + "https://nb.example/api", "super-secret-token", transport=httpx.MockTransport(handler) + ) + + with self.assertRaises(NocoBaseHttpError) as caught: + client.add_attachment_ids(7, [8]) + + self.assertEqual(caught.exception.status_code, 401) + self.assertNotIn("super-secret-token", str(caught.exception)) + self.assertIn("", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 6421824..453aebc 100644 --- a/uv.lock +++ b/uv.lock @@ -352,11 +352,17 @@ name = "nocobase-crm-mcp" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "mcp", extra = ["cli"] }, + { name = "python-multipart" }, ] [package.metadata] -requires-dist = [{ name = "mcp", extras = ["cli"], specifier = ">=1.27,<2" }] +requires-dist = [ + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.27,<2" }, + { name = "python-multipart", specifier = ">=0.0.9,<1" }, +] [[package]] name = "pycparser"