feat: add project config and NocoBase HTTP client

This commit is contained in:
nac
2026-07-13 01:31:12 +00:00
parent 3be6dee45e
commit 7ab9f57500
5 changed files with 234 additions and 2 deletions
+49 -1
View File
@@ -2,11 +2,21 @@ from __future__ import annotations
import json import json
import os import os
from dataclasses import dataclass, replace from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
PROJECT_PROGRESS_VALUES = {
"询价阶段": "fk9xj8akn46",
"已报价": "k4w2vcz819w",
"送样": "g95vqeetu83",
"已定点": "fe0peg505d7",
"SOP": "a9t9awhtt3r",
"终止": "a9t9awhtsfasg3r",
}
@dataclass(frozen=True) @dataclass(frozen=True)
class CustomerConfig: class CustomerConfig:
resource: str = "customers" resource: str = "customers"
@@ -109,6 +119,22 @@ class UserConfig:
object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"]) 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) @dataclass(frozen=True)
class CrmConfig: class CrmConfig:
env_name: str = "nb-mcp" env_name: str = "nb-mcp"
@@ -117,6 +143,7 @@ class CrmConfig:
contact: ContactConfig = ContactConfig() contact: ContactConfig = ContactConfig()
followup: FollowupConfig = FollowupConfig() followup: FollowupConfig = FollowupConfig()
user: UserConfig = UserConfig() user: UserConfig = UserConfig()
project: ProjectConfig = ProjectConfig()
def _load_json(path: Path) -> dict[str, Any]: 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: def load_config(path: str | Path | None = None) -> CrmConfig:
configured_path = path or os.environ.get("NOCOBASE_MCP_CONFIG") configured_path = path or os.environ.get("NOCOBASE_MCP_CONFIG")
raw: dict[str, Any] = {} raw: dict[str, Any] = {}
@@ -228,4 +275,5 @@ def load_config(path: str | Path | None = None) -> CrmConfig:
contact=_contact_config(collections.get("contacts")), contact=_contact_config(collections.get("contacts")),
followup=_followup_config(collections.get("followups")), followup=_followup_config(collections.get("followups")),
user=_user_config(raw.get("users") or raw.get("user")), user=_user_config(raw.get("users") or raw.get("user")),
project=_project_config(raw.get("projects")),
) )
+83
View File
@@ -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, "<redacted>")
+2
View File
@@ -4,7 +4,9 @@ version = "0.1.0"
description = "Read-only MCP server for querying NocoBase CRM data through nb CLI." description = "Read-only MCP server for querying NocoBase CRM data through nb CLI."
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"httpx>=0.27,<1",
"mcp[cli]>=1.27,<2", "mcp[cli]>=1.27,<2",
"python-multipart>=0.0.9,<1",
] ]
[project.scripts] [project.scripts]
+93
View File
@@ -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("<redacted>", str(caught.exception))
if __name__ == "__main__":
unittest.main()
Generated
+7 -1
View File
@@ -352,11 +352,17 @@ name = "nocobase-crm-mcp"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] }, { name = "mcp", extra = ["cli"] },
{ name = "python-multipart" },
] ]
[package.metadata] [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]] [[package]]
name = "pycparser" name = "pycparser"