Merge branch 'feat/project-write-upload'
# Conflicts: # README.md # nocobase_crm_mcp/server.py # scripts/manage_mcp_service.py # tests/test_mcp_service.py # tests/test_nb_client.py # tests/test_server.py
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# NocoBase CRM MCP
|
||||
|
||||
Read-only MCP server for remote agents that need customer, contact, follow-up, and CRM overview data from NocoBase.
|
||||
MCP server for remote agents that need customer, contact, follow-up, CRM overview, and tightly scoped project write access in NocoBase.
|
||||
|
||||
The server calls the official `nb api resource list|get|query` commands. It does not expose create, update, destroy, or arbitrary command execution tools.
|
||||
The default stdio server remains read-only. Its managed HTTP mode adds only fixed-schema project create/update tools and authenticated attachment uploads; it never exposes arbitrary collections, fields, commands, or delete operations.
|
||||
|
||||
## Configure
|
||||
|
||||
@@ -81,6 +81,43 @@ Tool runtime failures are returned as structured errors:
|
||||
}
|
||||
```
|
||||
|
||||
## Project writes and uploads
|
||||
|
||||
Managed HTTP mode exposes two additional MCP tools:
|
||||
|
||||
- `create_project(name, customer_id?, inquiry_date?, progress?, applications?, part_numbers?, period_usage?, details?, attachment_ids?)`
|
||||
- `update_project(project_id, name?, customer_id?, inquiry_date?, progress?, applications?, part_numbers?, period_usage?, details?, add_attachment_ids?, remove_attachment_ids?)`
|
||||
|
||||
They target only `t_projects` and the fixed field mapping in `projects` in `config.json`. A project name is required for creation; `inquiry_date` uses `YYYY-MM-DD`; progress accepts only `询价阶段`, `已报价`, `送样`, `已定点`, `SOP`, or `终止`. Attachments are added or removed incrementally—there is no replacement attachment-list operation.
|
||||
|
||||
The same HTTP service exposes authenticated `POST /uploads`. Submit exactly one multipart field named `file`; the default single-file limit is 25 MiB (`NOCOBASE_MCP_UPLOAD_MAX_BYTES=26214400`). A successful upload returns an `upload_id`, which callers can pass to either project tool. The upload route and `/mcp` use the same MCP bearer token.
|
||||
|
||||
HTTP write mode also requires these server-side NocoBase credentials. Do not put them in `config.json`, pass them to MCP clients, or log them:
|
||||
|
||||
- `NOCOBASE_MCP_UPLOAD_API_BASE_URL` (for example `https://nocobase.example/api`)
|
||||
- `NOCOBASE_MCP_UPLOAD_JWT` (a NocoBase user JWT)
|
||||
|
||||
`scripts/mcp_service.sh start` creates `.runtime/nocobase-crm-mcp.env` with mode `0600`, its parent directory with mode `0700`, and only the non-secret upload size setting. HTTP write mode fails at startup with the missing variable name when either credential is absent.
|
||||
|
||||
First-install flow:
|
||||
|
||||
```bash
|
||||
# Creates the private env file; the service is expected to fail until credentials are added.
|
||||
bash scripts/mcp_service.sh start
|
||||
|
||||
# Add server-side NocoBase credentials to the systemd EnvironmentFile.
|
||||
cat >> .runtime/nocobase-crm-mcp.env <<'EOF'
|
||||
NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nocobase.example/api
|
||||
NOCOBASE_MCP_UPLOAD_JWT=<user-jwt>
|
||||
EOF
|
||||
|
||||
# Start the service with the newly configured private env file.
|
||||
bash scripts/mcp_service.sh start
|
||||
curl -X POST -H "Authorization: Bearer <mcp-token>" -F "file=@quote.pdf" http://<host>:10087/uploads
|
||||
```
|
||||
|
||||
The NocoBase credentials belong in `.runtime/nocobase-crm-mcp.env`, not in shell-local variables or client configuration. Token rotation preserves these operator-managed env lines. An uploaded attachment that is not yet associated with a project is intentionally retained; this release does not auto-clean orphan attachments. It also provides no delete operation for projects or attachments.
|
||||
|
||||
## Permissions
|
||||
|
||||
Runtime tools are read-only, but the role behind `nb-mcp` needs these read permissions:
|
||||
@@ -96,7 +133,15 @@ nb api data-modeling collections fields list --collection-name <collection> --en
|
||||
|
||||
Use a super-admin or configuration-capable role for this one-time discovery step. The remote Agent does not need data-modeling access after `config.json` is generated.
|
||||
|
||||
Do not grant write permissions such as collection apply/destroy, fields apply, or business data create/update/destroy for this MCP.
|
||||
For the read-only stdio/default server, do not grant write permissions such as collection apply/destroy, fields apply, or business data create/update/destroy.
|
||||
|
||||
For the managed HTTP write mode only, the NocoBase JWT role additionally needs:
|
||||
|
||||
- `t_projects`: create and update
|
||||
- `attachments`: upload/create
|
||||
- `t_projects.f_projects_attachments`: add and remove attachment relations
|
||||
|
||||
Do not grant project or attachment delete permissions. This server does not offer deletes, and it retains uploaded but unassociated attachments.
|
||||
|
||||
## Remote HTTP MCP
|
||||
|
||||
@@ -129,6 +174,8 @@ If `nb` is installed somewhere unusual, set its absolute path before starting:
|
||||
NOCOBASE_MCP_NB_BIN=/absolute/path/to/nb bash scripts/mcp_service.sh start
|
||||
```
|
||||
|
||||
The generated env file also includes the non-secret 25 MiB upload limit. The service fails at startup until the operator adds `NOCOBASE_MCP_UPLOAD_API_BASE_URL` and `NOCOBASE_MCP_UPLOAD_JWT` to that private file. Run `start` again after adding them; `new-token` preserves those operator-managed credential lines.
|
||||
|
||||
Show the saved URL/header/client config:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
"fields": ["id", "username", "nickname", "email", "f_yh_english_name"],
|
||||
"matchFields": ["username", "nickname", "email", "f_yh_english_name"]
|
||||
},
|
||||
"projects": {
|
||||
"resource": "t_projects",
|
||||
"idField": "id",
|
||||
"nameField": "f_project_name",
|
||||
"customerField": "client_ID",
|
||||
"inquiryDateField": "xm_project_inquiry_date",
|
||||
"progressField": "f_projects_progress",
|
||||
"applicationsField": "f_projects_applications",
|
||||
"partNumbersField": "f_projects_part_numbers",
|
||||
"periodUsageField": "f_projects_period_usage",
|
||||
"detailsField": "f_projects_details",
|
||||
"attachmentField": "f_projects_attachments"
|
||||
},
|
||||
"collections": {
|
||||
"customers": {
|
||||
"resource": "t_nt3pjadelqi",
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Set as AbstractSet
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
@@ -90,6 +91,32 @@ class NbClient:
|
||||
self._append_repeated(command, "--except", except_fields)
|
||||
return self._run_json(command)
|
||||
|
||||
def create_record(
|
||||
self,
|
||||
resource: str,
|
||||
values: Mapping[str, Any],
|
||||
whitelist: Sequence[str],
|
||||
) -> dict[str, Any]:
|
||||
self._validate_scalar_values(values)
|
||||
command = self._resource_command("create", resource)
|
||||
self._append_json(command, "--values", values)
|
||||
self._append_repeated(command, "--whitelist", whitelist)
|
||||
return self._run_json(command)
|
||||
|
||||
def update_record(
|
||||
self,
|
||||
resource: str,
|
||||
record_id: Any,
|
||||
values: Mapping[str, Any],
|
||||
whitelist: Sequence[str],
|
||||
) -> dict[str, Any]:
|
||||
self._validate_scalar_values(values)
|
||||
command = self._resource_command("update", resource)
|
||||
self._append_json_or_scalar(command, "--filter-by-tk", record_id)
|
||||
self._append_json(command, "--values", values)
|
||||
self._append_repeated(command, "--whitelist", whitelist)
|
||||
return self._run_json(command)
|
||||
|
||||
def query_records(
|
||||
self,
|
||||
resource: str,
|
||||
@@ -286,3 +313,11 @@ class NbClient:
|
||||
if value is None or value == "":
|
||||
return
|
||||
command.extend([flag, str(value)])
|
||||
|
||||
@staticmethod
|
||||
def _validate_scalar_values(values: Mapping[str, Any]) -> None:
|
||||
if not values:
|
||||
raise ValueError("values must not be empty")
|
||||
for field, value in values.items():
|
||||
if isinstance(value, (Mapping, list, AbstractSet, tuple)):
|
||||
raise ValueError(f"values[{field!r}] must be scalar")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
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:
|
||||
project_id_segment = _project_id_segment(project_id)
|
||||
response = self._client.post(
|
||||
f"/t_projects/{project_id_segment}/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>")
|
||||
|
||||
|
||||
def _project_id_segment(project_id: Any) -> str:
|
||||
return quote(validate_project_id(project_id), safe="")
|
||||
|
||||
|
||||
def validate_project_id(project_id: Any) -> str:
|
||||
if isinstance(project_id, bool) or not isinstance(project_id, (str, int)):
|
||||
raise ValueError("project_id must be a scalar identifier")
|
||||
if isinstance(project_id, str):
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id must not be blank")
|
||||
if project_id in {".", ".."}:
|
||||
raise ValueError("project_id must not be a dot path segment")
|
||||
return str(project_id)
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from .config import ProjectConfig
|
||||
from .nocobase_http import NocoBaseHttpError, validate_project_id
|
||||
|
||||
|
||||
def _configured_scalar_fields(config: ProjectConfig) -> tuple[str, ...]:
|
||||
return (
|
||||
config.name_field,
|
||||
config.customer_field,
|
||||
config.inquiry_date_field,
|
||||
config.progress_field,
|
||||
config.applications_field,
|
||||
config.part_numbers_field,
|
||||
config.period_usage_field,
|
||||
config.details_field,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_WHITELIST = _configured_scalar_fields(ProjectConfig())
|
||||
|
||||
|
||||
class _InvalidProjectInput(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class _InvalidProgress(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ProjectService:
|
||||
def __init__(self, config: ProjectConfig, nb_client: Any, http_client: Any) -> None:
|
||||
self.config = config
|
||||
self.nb_client = nb_client
|
||||
self.http_client = http_client
|
||||
self._whitelist = _configured_scalar_fields(config)
|
||||
|
||||
def create_project(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
customer_id: Any | None = None,
|
||||
inquiry_date: str | None = None,
|
||||
progress: str | None = None,
|
||||
applications: str | None = None,
|
||||
part_numbers: str | None = None,
|
||||
period_usage: str | None = None,
|
||||
details: str | None = None,
|
||||
attachment_ids: Sequence[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
values = self._project_values(
|
||||
name=name,
|
||||
customer_id=customer_id,
|
||||
inquiry_date=inquiry_date,
|
||||
progress=progress,
|
||||
applications=applications,
|
||||
part_numbers=part_numbers,
|
||||
period_usage=period_usage,
|
||||
details=details,
|
||||
require_name=True,
|
||||
)
|
||||
attachment_ids = _deduplicated_attachment_ids(attachment_ids)
|
||||
except _InvalidProgress:
|
||||
return _error("invalid_progress", "progress must be one of the configured project progress labels")
|
||||
except _InvalidProjectInput as error:
|
||||
return _error("invalid_project_input", str(error))
|
||||
|
||||
response = self.nb_client.create_record(self.config.resource, values, self._whitelist)
|
||||
project_id = _project_id(response, self.config.id_field)
|
||||
if not attachment_ids:
|
||||
return {"project_id": project_id}
|
||||
|
||||
try:
|
||||
self.http_client.add_attachment_ids(project_id, attachment_ids)
|
||||
except Exception as error:
|
||||
return _attachment_error(project_id, error)
|
||||
return {"project_id": project_id}
|
||||
|
||||
def update_project(
|
||||
self,
|
||||
project_id: Any,
|
||||
*,
|
||||
name: str | None = None,
|
||||
customer_id: Any | None = None,
|
||||
inquiry_date: str | None = None,
|
||||
progress: str | None = None,
|
||||
applications: str | None = None,
|
||||
part_numbers: str | None = None,
|
||||
period_usage: str | None = None,
|
||||
details: str | None = None,
|
||||
add_attachment_ids: Sequence[Any] | None = None,
|
||||
remove_attachment_ids: Sequence[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
_validate_project_id(project_id)
|
||||
values = self._project_values(
|
||||
name=name,
|
||||
customer_id=customer_id,
|
||||
inquiry_date=inquiry_date,
|
||||
progress=progress,
|
||||
applications=applications,
|
||||
part_numbers=part_numbers,
|
||||
period_usage=period_usage,
|
||||
details=details,
|
||||
require_name=False,
|
||||
)
|
||||
add_attachment_ids = _deduplicated_attachment_ids(add_attachment_ids)
|
||||
remove_attachment_ids = _deduplicated_attachment_ids(remove_attachment_ids)
|
||||
if _overlap(add_attachment_ids, remove_attachment_ids):
|
||||
raise _InvalidProjectInput("attachment IDs cannot be added and removed in the same request")
|
||||
if not values and not add_attachment_ids and not remove_attachment_ids:
|
||||
raise _InvalidProjectInput("update request must include project fields or attachment IDs")
|
||||
except _InvalidProgress:
|
||||
return _error("invalid_progress", "progress must be one of the configured project progress labels")
|
||||
except _InvalidProjectInput as error:
|
||||
return _error("invalid_project_input", str(error))
|
||||
|
||||
if values:
|
||||
self.nb_client.update_record(self.config.resource, project_id, values, self._whitelist)
|
||||
|
||||
try:
|
||||
if add_attachment_ids:
|
||||
self.http_client.add_attachment_ids(project_id, add_attachment_ids)
|
||||
if remove_attachment_ids:
|
||||
self.http_client.remove_attachment_ids(project_id, remove_attachment_ids)
|
||||
except Exception as error:
|
||||
return _attachment_error(project_id, error)
|
||||
return {"project_id": project_id}
|
||||
|
||||
def _project_values(
|
||||
self,
|
||||
*,
|
||||
name: str | None,
|
||||
customer_id: Any | None,
|
||||
inquiry_date: str | None,
|
||||
progress: str | None,
|
||||
applications: str | None,
|
||||
part_numbers: str | None,
|
||||
period_usage: str | None,
|
||||
details: str | None,
|
||||
require_name: bool,
|
||||
) -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not (normalized_name := name.strip()):
|
||||
raise _InvalidProjectInput("name must not be blank")
|
||||
values[self.config.name_field] = normalized_name
|
||||
elif require_name:
|
||||
raise _InvalidProjectInput("name is required")
|
||||
|
||||
if customer_id is not None:
|
||||
_validate_identifier(customer_id, "customer_id")
|
||||
values[self.config.customer_field] = customer_id
|
||||
if inquiry_date is not None:
|
||||
if not isinstance(inquiry_date, str):
|
||||
raise _InvalidProjectInput("inquiry_date must be a YYYY-MM-DD date")
|
||||
try:
|
||||
parsed_date = date.fromisoformat(inquiry_date)
|
||||
if parsed_date.isoformat() != inquiry_date:
|
||||
raise ValueError("date must use YYYY-MM-DD format")
|
||||
values[self.config.inquiry_date_field] = parsed_date.isoformat()
|
||||
except ValueError as error:
|
||||
raise _InvalidProjectInput("inquiry_date must be a YYYY-MM-DD date") from error
|
||||
if progress is not None:
|
||||
if not isinstance(progress, str):
|
||||
raise _InvalidProjectInput("progress must be a scalar value")
|
||||
if progress not in self.config.progress_values:
|
||||
raise _InvalidProgress
|
||||
values[self.config.progress_field] = self.config.progress_values[progress]
|
||||
|
||||
for value, field in (
|
||||
(applications, self.config.applications_field),
|
||||
(part_numbers, self.config.part_numbers_field),
|
||||
(period_usage, self.config.period_usage_field),
|
||||
(details, self.config.details_field),
|
||||
):
|
||||
if value is not None:
|
||||
if not isinstance(value, str):
|
||||
raise _InvalidProjectInput(f"{field} must be a scalar value")
|
||||
values[field] = value
|
||||
return values
|
||||
|
||||
|
||||
def _deduplicated_attachment_ids(attachment_ids: Sequence[Any] | None) -> list[Any]:
|
||||
if attachment_ids is None:
|
||||
return []
|
||||
if isinstance(attachment_ids, (str, bytes)) or not isinstance(attachment_ids, Sequence):
|
||||
raise _InvalidProjectInput("attachment IDs must be a list")
|
||||
|
||||
unique_ids: list[Any] = []
|
||||
for attachment_id in attachment_ids:
|
||||
try:
|
||||
_validate_identifier(attachment_id, "attachment IDs")
|
||||
except _InvalidProjectInput as error:
|
||||
raise _InvalidProjectInput("attachment IDs must be scalar values") from error
|
||||
if attachment_id is None:
|
||||
raise _InvalidProjectInput("attachment IDs must be scalar values")
|
||||
if attachment_id not in unique_ids:
|
||||
unique_ids.append(attachment_id)
|
||||
return unique_ids
|
||||
|
||||
|
||||
def _overlap(left: Sequence[Any], right: Sequence[Any]) -> bool:
|
||||
return any(left_id == right_id for left_id in left for right_id in right)
|
||||
|
||||
|
||||
def _project_id(response: Mapping[str, Any], id_field: str) -> Any:
|
||||
data = response.get("data")
|
||||
if not isinstance(data, Mapping) or id_field not in data:
|
||||
raise ValueError(f"NocoBase create response did not contain {id_field!r}")
|
||||
return data[id_field]
|
||||
|
||||
|
||||
def _validate_identifier(value: Any, name: str) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
raise _InvalidProjectInput(f"{name} must be a scalar identifier")
|
||||
if isinstance(value, str) and not value.strip():
|
||||
raise _InvalidProjectInput(f"{name} must not be blank")
|
||||
|
||||
|
||||
def _validate_project_id(project_id: Any) -> None:
|
||||
try:
|
||||
validate_project_id(project_id)
|
||||
except ValueError as error:
|
||||
raise _InvalidProjectInput(str(error)) from error
|
||||
|
||||
|
||||
def _attachment_error(project_id: Any, error: Exception) -> dict[str, Any]:
|
||||
if isinstance(error, NocoBaseHttpError):
|
||||
if error.status_code == 404:
|
||||
code = "attachment_not_found"
|
||||
elif error.status_code in {401, 403}:
|
||||
code = "nb_upload_unauthorized"
|
||||
else:
|
||||
code = "nb_upload_failed"
|
||||
else:
|
||||
code = "nb_upload_failed"
|
||||
return {"project_id": project_id, "error": {"code": code, "message": str(error)}}
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> dict[str, Any]:
|
||||
return {"error": {"code": code, "message": message}}
|
||||
+113
-6
@@ -4,9 +4,13 @@ import argparse
|
||||
import hmac
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Callable
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
try:
|
||||
from pydantic import Field
|
||||
except ModuleNotFoundError:
|
||||
@@ -25,6 +29,9 @@ except ModuleNotFoundError:
|
||||
from .config import load_config
|
||||
from .crm import CrmService
|
||||
from .nb_client import NbClient, NbCommandError
|
||||
from .nocobase_http import NocoBaseHttpClient
|
||||
from .projects import ProjectService
|
||||
from .uploads import UploadEndpoint
|
||||
|
||||
|
||||
DEFAULT_HTTP_HOST = "0.0.0.0"
|
||||
@@ -32,6 +39,10 @@ DEFAULT_HTTP_PORT = 10087
|
||||
DEFAULT_HTTP_PATH = "/mcp"
|
||||
DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN"
|
||||
ENABLE_CONFIG_TOOLS_ENV = "NOCOBASE_MCP_ENABLE_CONFIG_TOOLS"
|
||||
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"
|
||||
DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _bearer_token(authorization: str) -> str:
|
||||
@@ -135,6 +146,7 @@ def _tool_error(*, code: str, message: str, hint: str, retryable: bool) -> dict[
|
||||
def create_server(
|
||||
*,
|
||||
crm_service: CrmService | None = None,
|
||||
project_service: ProjectService | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
readonly: bool = True,
|
||||
host: str = DEFAULT_HTTP_HOST,
|
||||
@@ -145,8 +157,9 @@ def create_server(
|
||||
) -> Any:
|
||||
if FastMCP is None:
|
||||
raise RuntimeError("Missing MCP SDK. Install dependencies with: uv sync")
|
||||
del readonly
|
||||
service = crm_service or _make_service(config_path)
|
||||
if not readonly and project_service is None:
|
||||
raise ValueError("project_service is required when readonly is false")
|
||||
transport_security = None
|
||||
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
|
||||
transport_security = TransportSecuritySettings(
|
||||
@@ -307,6 +320,66 @@ def create_server(
|
||||
)
|
||||
)
|
||||
|
||||
if not readonly:
|
||||
|
||||
@server.tool()
|
||||
def create_project(
|
||||
name: Annotated[str, Field(description="Project name. It must not be blank.")],
|
||||
customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None,
|
||||
inquiry_date: Annotated[str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.")] = None,
|
||||
progress: Annotated[str | None, Field(description="Optional configured Chinese project progress label.")] = None,
|
||||
applications: Annotated[str | None, Field(description="Optional project applications.")] = None,
|
||||
part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None,
|
||||
period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None,
|
||||
details: Annotated[str | None, Field(description="Optional project details.")] = None,
|
||||
attachment_ids: Annotated[list[Any] | None, Field(description="Optional uploaded attachment IDs to associate.")] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create one fixed-schema NocoBase project and optionally associate uploaded attachments."""
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
@@ -317,8 +390,33 @@ def _read_token_from_env(env_name: str) -> str:
|
||||
return token
|
||||
|
||||
|
||||
def create_http_app(server: Any, token: str) -> Any:
|
||||
return BearerTokenAuthMiddleware(server.streamable_http_app(), 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)
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/uploads", endpoint=upload_endpoint, methods=["POST"]),
|
||||
Mount("/", app=server.streamable_http_app()),
|
||||
]
|
||||
)
|
||||
return BearerTokenAuthMiddleware(app, token)
|
||||
|
||||
|
||||
def _read_upload_environment() -> tuple[str, str, int]:
|
||||
api_base_url = os.environ.get(UPLOAD_API_BASE_URL_ENV, "").strip()
|
||||
if not api_base_url:
|
||||
raise RuntimeError(f"{UPLOAD_API_BASE_URL_ENV} must be set for HTTP write mode")
|
||||
jwt = os.environ.get(UPLOAD_JWT_ENV, "").strip()
|
||||
if not jwt:
|
||||
raise RuntimeError(f"{UPLOAD_JWT_ENV} must be set for HTTP write mode")
|
||||
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, jwt, max_bytes
|
||||
|
||||
|
||||
def run_http_server(
|
||||
@@ -334,16 +432,25 @@ def run_http_server(
|
||||
import uvicorn
|
||||
|
||||
token = _read_token_from_env(token_env)
|
||||
api_base_url, jwt, max_bytes = _read_upload_environment()
|
||||
config = load_config(config_path)
|
||||
nb_bin = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip() or "nb"
|
||||
nb_client = NbClient(env_name=config.env_name, data_source=config.data_source, nb_bin=nb_bin)
|
||||
crm_service = CrmService(config, nb_client)
|
||||
http_client = NocoBaseHttpClient(api_base_url, jwt)
|
||||
project_service = ProjectService(config.project, nb_client, http_client)
|
||||
server = create_server(
|
||||
config_path=config_path,
|
||||
readonly=True,
|
||||
crm_service=crm_service,
|
||||
project_service=project_service,
|
||||
readonly=False,
|
||||
host=host,
|
||||
port=port,
|
||||
streamable_http_path=streamable_http_path,
|
||||
allowed_hosts=allowed_hosts,
|
||||
allowed_origins=allowed_origins,
|
||||
)
|
||||
app = create_http_app(server, token)
|
||||
upload_endpoint = UploadEndpoint(http_client, max_bytes=max_bytes, temp_dir=Path(tempfile.gettempdir()))
|
||||
app = create_http_app(server, token, upload_endpoint=upload_endpoint)
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import tempfile
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.datastructures import UploadFile
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from .nocobase_http import NocoBaseHttpError
|
||||
|
||||
|
||||
_COPY_CHUNK_BYTES = 1024 * 1024
|
||||
_MULTIPART_OVERHEAD_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class _UploadTooLarge(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class UploadEndpoint:
|
||||
"""ASGI endpoint that forwards one bounded multipart file to NocoBase."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: Any,
|
||||
max_bytes: int,
|
||||
temp_dir: str | Path,
|
||||
allowed_mime_types: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
self._http_client = http_client
|
||||
self._max_bytes = max_bytes
|
||||
self._temp_dir = Path(temp_dir)
|
||||
self._allowed_mime_types = tuple(allowed_mime_types) if allowed_mime_types is not None else None
|
||||
|
||||
async def __call__(self, request: Request) -> JSONResponse:
|
||||
if request.method != "POST":
|
||||
return _error_response("invalid_upload", "Uploads require POST", 405)
|
||||
if not _is_multipart(request):
|
||||
return _error_response("invalid_upload", "Uploads require multipart form data", 400)
|
||||
|
||||
try:
|
||||
request = await _bounded_request(request, self._max_bytes + _MULTIPART_OVERHEAD_BYTES)
|
||||
form = await request.form()
|
||||
except _UploadTooLarge:
|
||||
return _error_response("upload_too_large", "Upload exceeds the configured size limit", 413)
|
||||
except Exception:
|
||||
return _error_response("invalid_upload", "Could not read upload form data", 400)
|
||||
|
||||
items = list(form.multi_items())
|
||||
uploads = [(name, value) for name, value in items if isinstance(value, UploadFile)]
|
||||
if len(items) != 1 or len(uploads) != 1 or uploads[0][0] != "file":
|
||||
await _close_uploads(value for _, value in uploads)
|
||||
return _error_response("invalid_upload", "Provide exactly one file upload", 400)
|
||||
|
||||
upload = uploads[0][1]
|
||||
if not isinstance(upload, UploadFile):
|
||||
return _error_response("invalid_upload", "Provide exactly one file upload", 400)
|
||||
|
||||
filename = upload.filename or ""
|
||||
mimetype = upload.content_type
|
||||
if self._allowed_mime_types is not None and not any(
|
||||
fnmatch.fnmatch(mimetype or "", allowed) for allowed in self._allowed_mime_types
|
||||
):
|
||||
await upload.close()
|
||||
return _error_response("unsupported_media_type", "This media type is not allowed", 415)
|
||||
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, dir=self._temp_dir) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
size = 0
|
||||
while chunk := await upload.read(_COPY_CHUNK_BYTES):
|
||||
size += len(chunk)
|
||||
if size > self._max_bytes:
|
||||
return _error_response("upload_too_large", "Upload exceeds the configured size limit", 413)
|
||||
temporary.write(chunk)
|
||||
|
||||
attachment = self._http_client.upload_attachment(temporary_path, filename, mimetype)
|
||||
attachment_id = _attachment_id(attachment)
|
||||
if attachment_id is None:
|
||||
return _error_response("nb_upload_failed", "NocoBase attachment upload failed", 502)
|
||||
return JSONResponse(
|
||||
{"upload_id": attachment_id, "filename": filename, "size": size, "mimetype": mimetype}
|
||||
)
|
||||
except NocoBaseHttpError as error:
|
||||
code = "nb_upload_unauthorized" if error.status_code in {401, 403} else "nb_upload_failed"
|
||||
return _error_response(code, "NocoBase attachment upload failed", 502)
|
||||
except Exception:
|
||||
return _error_response("nb_upload_failed", "NocoBase attachment upload failed", 502)
|
||||
finally:
|
||||
await upload.close()
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _is_multipart(request: Request) -> bool:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
return content_type.split(";", 1)[0].strip().lower() == "multipart/form-data"
|
||||
|
||||
|
||||
async def _bounded_request(request: Request, max_body_bytes: int) -> Request:
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > max_body_bytes:
|
||||
raise _UploadTooLarge
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
received_bytes = 0
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
message = await request.receive()
|
||||
if message.get("type") == "http.request":
|
||||
body = message.get("body", b"")
|
||||
received_bytes += len(body)
|
||||
if received_bytes > max_body_bytes:
|
||||
raise _UploadTooLarge
|
||||
chunks.append(body)
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
elif message.get("type") == "http.disconnect":
|
||||
break
|
||||
|
||||
body = b"".join(chunks)
|
||||
replayed = False
|
||||
|
||||
async def receive() -> dict[str, Any]:
|
||||
nonlocal replayed
|
||||
if replayed:
|
||||
return {"type": "http.disconnect"}
|
||||
replayed = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(request.scope, receive=receive)
|
||||
|
||||
|
||||
def _attachment_id(attachment: Any) -> int | None:
|
||||
if not isinstance(attachment, Mapping):
|
||||
return None
|
||||
attachment_id = attachment.get("id")
|
||||
if isinstance(attachment_id, int) and not isinstance(attachment_id, bool):
|
||||
return attachment_id
|
||||
return None
|
||||
|
||||
|
||||
async def _close_uploads(uploads: Iterable[UploadFile]) -> None:
|
||||
for upload in uploads:
|
||||
await upload.close()
|
||||
|
||||
|
||||
def _error_response(code: str, message: str, status_code: int) -> JSONResponse:
|
||||
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status_code)
|
||||
@@ -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]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manage the user-level read-only HTTP NocoBase CRM MCP systemd service."""
|
||||
"""Manage the user-level HTTP NocoBase CRM MCP systemd service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,6 +28,19 @@ 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
|
||||
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",
|
||||
"NOCOBASE_MCP_UPLOAD_MAX_BYTES",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -103,7 +116,7 @@ def render_service_unit(
|
||||
)
|
||||
return (
|
||||
"[Unit]\n"
|
||||
"Description=NocoBase CRM readonly HTTP MCP server\n"
|
||||
"Description=NocoBase CRM HTTP MCP server\n"
|
||||
"After=network-online.target\n"
|
||||
"Wants=network-online.target\n"
|
||||
"\n"
|
||||
@@ -132,6 +145,7 @@ def write_token_env_file(
|
||||
config_path: str = DEFAULT_CONFIG_PATH,
|
||||
nb_bin: str | Path = DEFAULT_NB_BIN,
|
||||
) -> None:
|
||||
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)
|
||||
@@ -143,9 +157,26 @@ def write_token_env_file(
|
||||
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"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 _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(
|
||||
@@ -424,7 +455,7 @@ def service_status(*, project_root: Path) -> str:
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Manage the readonly HTTP NocoBase CRM MCP user service.")
|
||||
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 remote MCP client configuration.")
|
||||
|
||||
@@ -117,6 +117,36 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "Could not find nb"):
|
||||
manage_mcp_service.find_nb_path()
|
||||
|
||||
def test_generated_env_has_size_but_no_upload_jwt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = Path(tmp) / "mcp.env"
|
||||
|
||||
manage_mcp_service.write_token_env_file(env_path, "token")
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_MAX_BYTES=26214400\n", content)
|
||||
self.assertNotIn("NOCOBASE_MCP_UPLOAD_JWT", content)
|
||||
self.assertNotIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL", content)
|
||||
|
||||
def test_token_rotation_preserves_operator_credentials(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = Path(tmp) / "mcp.env"
|
||||
env_path.write_text(
|
||||
"NOCOBASE_MCP_TOKEN=old-token\n"
|
||||
"NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nocobase.example/api\n"
|
||||
"NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manage_mcp_service.write_token_env_file(env_path, "new-token", nb_bin="/opt/node/bin/nb")
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_MAX_BYTES=26214400\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nocobase.example/api\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n", content)
|
||||
|
||||
def test_render_remote_config_prints_url_and_authorization_header(self) -> None:
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10"],
|
||||
@@ -155,6 +185,36 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("http://10.0.0.2:10087/mcp", output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_new_token_preserves_operator_upload_settings_and_other_env_lines(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(env_path, "old-token")
|
||||
with env_path.open("a", encoding="utf-8") as env_file:
|
||||
env_file.write("# operator-managed upload credentials\n")
|
||||
env_file.write("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nb.example/api\n")
|
||||
env_file.write("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n")
|
||||
env_file.write("EXTRA_OPERATOR_SETTING=preserve-me\n")
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
|
||||
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
|
||||
):
|
||||
manage_mcp_service.new_token(project_root=project_root)
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", content)
|
||||
self.assertIn("# operator-managed upload credentials\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nb.example/api\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n", content)
|
||||
self.assertIn("EXTRA_OPERATOR_SETTING=preserve-me\n", content)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_start_service_uses_saved_token_and_restarts_service(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
|
||||
@@ -99,6 +99,77 @@ class NbClientTests(unittest.TestCase):
|
||||
self.assertEqual(calls[0]["cmd"][0], "/opt/node/bin/nb")
|
||||
self.assertEqual(calls[0]["env"]["PATH"], "/opt/node/bin:/usr/bin")
|
||||
|
||||
def test_create_record_uses_values_and_whitelist(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"id":7}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
client.create_record("t_projects", {"f_project_name": "EV inverter"}, ["f_project_name"])
|
||||
|
||||
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "create"])
|
||||
self.assertEqual(
|
||||
json.loads(commands[0][commands[0].index("--values") + 1]),
|
||||
{"f_project_name": "EV inverter"},
|
||||
)
|
||||
self.assertEqual(commands[0][commands[0].index("--whitelist") + 1], "f_project_name")
|
||||
|
||||
def test_update_record_uses_filter_by_tk(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"id":7}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
client.update_record("t_projects", 7, {"client_ID": 3}, ["client_ID"])
|
||||
|
||||
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "update"])
|
||||
self.assertEqual(commands[0][commands[0].index("--filter-by-tk") + 1], "7")
|
||||
|
||||
def test_record_writes_reject_empty_values_without_running_command(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
client.create_record("t_projects", {}, ["f_project_name"])
|
||||
with self.assertRaises(ValueError):
|
||||
client.update_record("t_projects", 7, {}, ["f_project_name"])
|
||||
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_record_writes_reject_nested_values_without_running_command(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
nested_values = [
|
||||
{"client": {"id": 3}},
|
||||
{"client_ids": [3]},
|
||||
{"client_ids": {3}},
|
||||
{"client_ids": (3,)},
|
||||
]
|
||||
|
||||
for values in nested_values:
|
||||
with self.subTest(operation="create", values=values):
|
||||
with self.assertRaises(ValueError):
|
||||
client.create_record("t_projects", values, list(values))
|
||||
with self.subTest(operation="update", values=values):
|
||||
with self.assertRaises(ValueError):
|
||||
client.update_record("t_projects", 7, values, list(values))
|
||||
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_list_collection_fields_uses_data_modeling_command(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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_attachment_association_encodes_project_id_as_one_path_segment(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("../project/7?#", [8])
|
||||
|
||||
self.assertEqual(
|
||||
str(requests[0].url),
|
||||
"https://nb.example/api/t_projects/..%2Fproject%2F7%3F%23/f_projects_attachments:add",
|
||||
)
|
||||
|
||||
def test_attachment_association_rejects_non_scalar_project_id_before_request(self) -> None:
|
||||
client = NocoBaseHttpClient(
|
||||
"https://nb.example/api",
|
||||
"jwt",
|
||||
transport=httpx.MockTransport(lambda request: self.fail("request must not be sent")),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "project_id"):
|
||||
client.add_attachment_ids({"id": 7}, [8])
|
||||
|
||||
def test_attachment_association_rejects_dot_path_project_ids_before_request(self) -> None:
|
||||
client = NocoBaseHttpClient(
|
||||
"https://nb.example/api",
|
||||
"jwt",
|
||||
transport=httpx.MockTransport(lambda request: self.fail("request must not be sent")),
|
||||
)
|
||||
|
||||
for project_id in (".", ".."):
|
||||
with self.subTest(project_id=project_id), self.assertRaisesRegex(ValueError, "project_id"):
|
||||
client.add_attachment_ids(project_id, [8])
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,209 @@
|
||||
import unittest
|
||||
|
||||
from nocobase_crm_mcp.config import ProjectConfig
|
||||
from nocobase_crm_mcp.nocobase_http import NocoBaseHttpError
|
||||
from nocobase_crm_mcp.projects import PROJECT_WHITELIST, ProjectService
|
||||
|
||||
|
||||
class FakeNbClient:
|
||||
def __init__(self) -> None:
|
||||
self.create_calls: list[tuple[str, dict, tuple[str, ...]]] = []
|
||||
self.update_calls: list[tuple[str, object, dict, tuple[str, ...]]] = []
|
||||
|
||||
def create_record(self, resource, values, whitelist):
|
||||
self.create_calls.append((resource, dict(values), tuple(whitelist)))
|
||||
return {"data": {"id": 41}}
|
||||
|
||||
def update_record(self, resource, project_id, values, whitelist):
|
||||
self.update_calls.append((resource, project_id, dict(values), tuple(whitelist)))
|
||||
return {"data": {"id": project_id}}
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self) -> None:
|
||||
self.add_calls: list[tuple[object, list[object]]] = []
|
||||
self.remove_calls: list[tuple[object, list[object]]] = []
|
||||
self.add_error: Exception | None = None
|
||||
self.remove_error: Exception | None = None
|
||||
|
||||
def add_attachment_ids(self, project_id, attachment_ids) -> None:
|
||||
self.add_calls.append((project_id, list(attachment_ids)))
|
||||
if self.add_error:
|
||||
raise self.add_error
|
||||
|
||||
def remove_attachment_ids(self, project_id, attachment_ids) -> None:
|
||||
self.remove_calls.append((project_id, list(attachment_ids)))
|
||||
if self.remove_error:
|
||||
raise self.remove_error
|
||||
|
||||
|
||||
class ProjectServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.nb = FakeNbClient()
|
||||
self.http = FakeHttpClient()
|
||||
self.config = ProjectConfig()
|
||||
self.service = ProjectService(self.config, self.nb, self.http)
|
||||
|
||||
def test_create_maps_fixed_fields_then_adds_attachment_ids(self) -> None:
|
||||
result = self.service.create_project(
|
||||
name=" EV inverter ", customer_id=3, progress="SOP", attachment_ids=[8, 9]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.nb.create_calls,
|
||||
[
|
||||
(
|
||||
"t_projects",
|
||||
{
|
||||
"f_project_name": "EV inverter",
|
||||
"client_ID": 3,
|
||||
"f_projects_progress": "a9t9awhtt3r",
|
||||
},
|
||||
PROJECT_WHITELIST,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.http.add_calls, [(41, [8, 9])])
|
||||
self.assertEqual(result["project_id"], 41)
|
||||
|
||||
def test_whitelist_contains_only_the_configured_scalar_fields(self) -> None:
|
||||
self.service.create_project(name="x")
|
||||
|
||||
self.assertEqual(
|
||||
PROJECT_WHITELIST,
|
||||
(
|
||||
self.config.name_field,
|
||||
self.config.customer_field,
|
||||
self.config.inquiry_date_field,
|
||||
self.config.progress_field,
|
||||
self.config.applications_field,
|
||||
self.config.part_numbers_field,
|
||||
self.config.period_usage_field,
|
||||
self.config.details_field,
|
||||
),
|
||||
)
|
||||
self.assertNotIn(self.config.attachment_field, PROJECT_WHITELIST)
|
||||
|
||||
def test_update_rejects_overlapping_attachment_lists_before_writing(self) -> None:
|
||||
result = self.service.update_project(41, add_attachment_ids=[8], remove_attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
self.assertEqual(self.http.remove_calls, [])
|
||||
|
||||
def test_invalid_date_progress_and_blank_name_have_stable_codes(self) -> None:
|
||||
self.assertEqual(
|
||||
self.service.create_project(name="x", inquiry_date="2026/07/12")["error"]["code"],
|
||||
"invalid_project_input",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.create_project(name="x", inquiry_date="20260712")["error"]["code"],
|
||||
"invalid_project_input",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.create_project(name="x", progress="草稿")["error"]["code"],
|
||||
"invalid_progress",
|
||||
)
|
||||
self.assertEqual(self.service.create_project(name=" \t ")["error"]["code"], "invalid_project_input")
|
||||
|
||||
def test_update_rejects_an_empty_request_before_writing(self) -> None:
|
||||
result = self.service.update_project(41)
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
|
||||
def test_create_rejects_nested_scalar_inputs_before_writing(self) -> None:
|
||||
cases = {
|
||||
"name": {"name": "x"},
|
||||
"customer_id": {"id": 3},
|
||||
"inquiry_date": ["2026-07-12"],
|
||||
"progress": ("SOP",),
|
||||
"applications": ["EV"],
|
||||
"part_numbers": ("123",),
|
||||
"period_usage": {"annual"},
|
||||
"details": {"note": "x"},
|
||||
}
|
||||
|
||||
for field, value in cases.items():
|
||||
with self.subTest(field=field):
|
||||
self.setUp()
|
||||
arguments = {"name": "x", **{field: value}}
|
||||
result = self.service.create_project(**arguments)
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.create_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_update_rejects_nested_project_id_before_any_client_call(self) -> None:
|
||||
for project_id in ({"id": 41}, [41], {41}, (41,)):
|
||||
with self.subTest(project_id=project_id):
|
||||
self.setUp()
|
||||
result = self.service.update_project(project_id, name="x", add_attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_update_rejects_dot_path_project_id_before_any_client_call(self) -> None:
|
||||
for project_id in (".", ".."):
|
||||
with self.subTest(project_id=project_id):
|
||||
self.setUp()
|
||||
result = self.service.update_project(project_id, name="x", add_attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_create_rejects_nested_attachment_id_before_any_client_call(self) -> None:
|
||||
result = self.service.create_project(name="x", attachment_ids=[{"id": 8}])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.create_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_update_writes_scalars_then_deduplicated_attachment_changes_in_order(self) -> None:
|
||||
result = self.service.update_project(
|
||||
41,
|
||||
inquiry_date="2026-07-12",
|
||||
add_attachment_ids=[8, 8, 9],
|
||||
remove_attachment_ids=[10, 10],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.nb.update_calls,
|
||||
[
|
||||
(
|
||||
"t_projects",
|
||||
41,
|
||||
{"xm_project_inquiry_date": "2026-07-12"},
|
||||
PROJECT_WHITELIST,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.http.add_calls, [(41, [8, 9])])
|
||||
self.assertEqual(self.http.remove_calls, [(41, [10])])
|
||||
self.assertEqual(result, {"project_id": 41})
|
||||
|
||||
def test_association_errors_keep_project_id_and_map_to_stable_codes(self) -> None:
|
||||
cases = [
|
||||
(404, "attachment_not_found"),
|
||||
(401, "nb_upload_unauthorized"),
|
||||
(403, "nb_upload_unauthorized"),
|
||||
(500, "nb_upload_failed"),
|
||||
]
|
||||
|
||||
for status_code, error_code in cases:
|
||||
with self.subTest(status_code=status_code):
|
||||
self.setUp()
|
||||
self.http.add_error = NocoBaseHttpError(status_code, "attachment request failed")
|
||||
|
||||
result = self.service.create_project(name="x", attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["project_id"], 41)
|
||||
self.assertEqual(result["error"]["code"], error_code)
|
||||
self.assertEqual(len(self.nb.create_calls), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+84
-1
@@ -2,9 +2,21 @@ import asyncio
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nocobase_crm_mcp import server as server_module
|
||||
from nocobase_crm_mcp.nb_client import NbCommandError
|
||||
from nocobase_crm_mcp.server import BearerTokenAuthMiddleware, FastMCP, create_http_app, create_server
|
||||
from nocobase_crm_mcp.server import (
|
||||
DEFAULT_HTTP_HOST,
|
||||
DEFAULT_HTTP_PATH,
|
||||
DEFAULT_HTTP_PORT,
|
||||
BearerTokenAuthMiddleware,
|
||||
FastMCP,
|
||||
create_http_app,
|
||||
create_server,
|
||||
run_http_server,
|
||||
)
|
||||
|
||||
|
||||
async def _ok_app(scope, receive, send):
|
||||
@@ -71,6 +83,19 @@ class MissingNbCrmService(FakeCrmService):
|
||||
raise FileNotFoundError(2, "No such file or directory", "nb")
|
||||
|
||||
|
||||
class FakeProjectService:
|
||||
def create_project(self, **kwargs):
|
||||
return {"project_id": 1}
|
||||
|
||||
def update_project(self, project_id, **kwargs):
|
||||
return {"project_id": project_id}
|
||||
|
||||
|
||||
class FakeUploadEndpoint:
|
||||
async def __call__(self, request):
|
||||
return JSONResponse({"uploaded": True})
|
||||
|
||||
|
||||
class ServerTests(unittest.TestCase):
|
||||
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
|
||||
app = BearerTokenAuthMiddleware(_ok_app, "secret")
|
||||
@@ -115,6 +140,21 @@ class ServerTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_write_server_exposes_fixed_project_tools(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
server = create_server(
|
||||
crm_service=FakeCrmService(),
|
||||
project_service=FakeProjectService(),
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
names = {tool.name for tool in asyncio.run(server.list_tools())}
|
||||
|
||||
self.assertTrue({"create_project", "update_project"}.issubset(names))
|
||||
self.assertNotIn("destroy_project", names)
|
||||
|
||||
def test_tool_schemas_expose_raw_opt_in_parameters(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
@@ -162,6 +202,49 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertIs(service["client"], nb_client.return_value)
|
||||
nb_client.assert_called_once_with(env_name="nb-mcp", data_source="main", nb_bin="/opt/node/bin/nb")
|
||||
|
||||
def test_upload_route_requires_the_same_bearer_token(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
server = create_server(crm_service=FakeCrmService(), readonly=True)
|
||||
app = create_http_app(server, "secret", upload_endpoint=FakeUploadEndpoint())
|
||||
|
||||
self.assertEqual(run_asgi_request(app, path="/uploads")[0]["status"], 401)
|
||||
|
||||
def test_upload_route_accepts_the_matching_bearer_token(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
server = create_server(crm_service=FakeCrmService(), readonly=True)
|
||||
|
||||
async def upload_endpoint(request):
|
||||
return JSONResponse({"uploaded": True})
|
||||
|
||||
app = create_http_app(server, "secret", upload_endpoint=upload_endpoint)
|
||||
|
||||
async def request_upload() -> httpx.Response:
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client:
|
||||
return await client.post("/uploads", headers={"Authorization": "Bearer secret"})
|
||||
|
||||
response = asyncio.run(request_upload())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"uploaded": True})
|
||||
|
||||
def test_http_write_mode_requires_upload_api_base_url(self) -> None:
|
||||
with patch.dict("os.environ", {"NOCOBASE_MCP_TOKEN": "secret"}, clear=True):
|
||||
with patch("uvicorn.run") as uvicorn_run:
|
||||
with self.assertRaisesRegex(RuntimeError, "NOCOBASE_MCP_UPLOAD_API_BASE_URL"):
|
||||
run_http_server(
|
||||
config_path=None,
|
||||
host=DEFAULT_HTTP_HOST,
|
||||
port=DEFAULT_HTTP_PORT,
|
||||
token_env="NOCOBASE_MCP_TOKEN",
|
||||
streamable_http_path=DEFAULT_HTTP_PATH,
|
||||
allowed_hosts=None,
|
||||
allowed_origins=None,
|
||||
)
|
||||
uvicorn_run.assert_not_called()
|
||||
|
||||
def test_config_template_tool_is_opt_in(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import gc
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
from nocobase_crm_mcp.nocobase_http import NocoBaseHttpError
|
||||
from nocobase_crm_mcp.uploads import UploadEndpoint
|
||||
|
||||
|
||||
AUTH = {"Authorization": "Bearer secret"}
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Path, str, str | None, bytes]] = []
|
||||
self.error: Exception | None = None
|
||||
|
||||
def upload_attachment(self, path: Path, filename: str, mimetype: str | None) -> dict[str, object]:
|
||||
self.calls.append((path, filename, mimetype, path.read_bytes()))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return {"id": 42}
|
||||
|
||||
|
||||
class UploadEndpointTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def _client_for(
|
||||
self,
|
||||
http_client: FakeHttpClient,
|
||||
temp_dir: str,
|
||||
*,
|
||||
max_bytes: int = 5,
|
||||
allowed_mime_types: tuple[str, ...] | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
endpoint = UploadEndpoint(http_client, max_bytes, temp_dir, allowed_mime_types)
|
||||
|
||||
async def upload_route(request):
|
||||
return await endpoint(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/uploads",
|
||||
upload_route,
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
]
|
||||
)
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
|
||||
|
||||
async def test_upload_returns_nocobase_attachment_id_and_removes_temp_file(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("quote.pdf", b"pdf", "application/pdf")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{"upload_id": 42, "filename": "quote.pdf", "size": 3, "mimetype": "application/pdf"},
|
||||
)
|
||||
self.assertEqual(http_client.calls[0][1:], ("quote.pdf", "application/pdf", b"pdf"))
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_rejects_missing_multiple_and_over_limit_files(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always", ResourceWarning)
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
missing = await client.post("/uploads", headers=AUTH, files={})
|
||||
multiple = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files=[
|
||||
("file", ("one.pdf", b"one", "application/pdf")),
|
||||
("file", ("two.pdf", b"two", "application/pdf")),
|
||||
],
|
||||
)
|
||||
over_limit = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("big.bin", b"x" * 6, "application/octet-stream")},
|
||||
)
|
||||
temp_paths = list(Path(temp_dir).iterdir())
|
||||
gc.collect()
|
||||
|
||||
self.assertEqual(missing.status_code, 400)
|
||||
self.assertEqual(missing.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(multiple.status_code, 400)
|
||||
self.assertEqual(multiple.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(over_limit.status_code, 413)
|
||||
self.assertEqual(over_limit.json()["error"]["code"], "upload_too_large")
|
||||
self.assertEqual(http_client.calls, [])
|
||||
self.assertEqual(temp_paths, [])
|
||||
self.assertFalse(any(issubclass(warning.category, ResourceWarning) for warning in caught))
|
||||
|
||||
async def test_upload_rejects_non_file_form_fields(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
files={"file": ("quote.pdf", b"pdf", "application/pdf")},
|
||||
data={"description": "quote"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(http_client.calls, [])
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_rejects_oversized_misreported_chunked_body_before_multipart_parsing(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
endpoint = UploadEndpoint(http_client, max_bytes=5, temp_dir="/tmp")
|
||||
parsed = False
|
||||
|
||||
async def forbidden_form(self):
|
||||
nonlocal parsed
|
||||
parsed = True
|
||||
self.fail("multipart parsing must not begin for an oversized body")
|
||||
|
||||
body = b"x" * (5 + 65537)
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/uploads",
|
||||
"headers": [
|
||||
(b"content-type", b"multipart/form-data; boundary=test"),
|
||||
(b"content-length", b"1"),
|
||||
],
|
||||
}
|
||||
messages = [
|
||||
{"type": "http.request", "body": body[:10], "more_body": True},
|
||||
{"type": "http.request", "body": body[10:], "more_body": False},
|
||||
]
|
||||
|
||||
async def receive():
|
||||
return messages.pop(0)
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
starlette_request = Request(scope, receive=receive)
|
||||
with patch("nocobase_crm_mcp.uploads.Request.form", new=forbidden_form):
|
||||
response = await endpoint(starlette_request)
|
||||
|
||||
self.assertEqual(response.status_code, 413)
|
||||
self.assertFalse(parsed)
|
||||
self.assertEqual(http_client.calls, [])
|
||||
|
||||
async def test_upload_rejects_disallowed_mime_type_before_calling_nocobase(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(
|
||||
http_client, temp_dir, allowed_mime_types=("image/*", "application/pdf")
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("notes.txt", b"notes", "text/plain")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 415)
|
||||
self.assertEqual(response.json()["error"]["code"], "unsupported_media_type")
|
||||
self.assertEqual(http_client.calls, [])
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_maps_upstream_errors_and_removes_temp_file(self) -> None:
|
||||
cases = ((401, "nb_upload_unauthorized"), (403, "nb_upload_unauthorized"), (500, "nb_upload_failed"))
|
||||
|
||||
for status_code, error_code in cases:
|
||||
with self.subTest(status_code=status_code), TemporaryDirectory() as temp_dir:
|
||||
http_client = FakeHttpClient()
|
||||
http_client.error = NocoBaseHttpError(status_code, "upstream failure")
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("quote.pdf", b"pdf", "application/pdf")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(response.json()["error"]["code"], error_code)
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_requires_post_and_multipart_form_data(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
get_response = await client.get("/uploads", headers=AUTH)
|
||||
json_response = await client.post("/uploads", headers=AUTH, json={"file": "not-a-file"})
|
||||
|
||||
self.assertEqual(get_response.status_code, 405)
|
||||
self.assertEqual(json_response.status_code, 400)
|
||||
self.assertEqual(json_response.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user