feat: add project config and NocoBase HTTP client
This commit is contained in:
@@ -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")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>")
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user