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:
nac
2026-07-13 03:00:21 +00:00
17 changed files with 1569 additions and 15 deletions
+50 -3
View File
@@ -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
+13
View File
@@ -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",
+49 -1
View File
@@ -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")),
)
+35
View File
@@ -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")
+100
View File
@@ -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)
+247
View File
@@ -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
View File
@@ -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")
+157
View File
@@ -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)
+2
View File
@@ -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]
+34 -3
View File
@@ -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.")
+60
View File
@@ -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"
+71
View File
@@ -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]] = []
+129
View File
@@ -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()
+209
View File
@@ -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
View File
@@ -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")
+209
View File
@@ -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()
Generated
+7 -1
View File
@@ -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"