feat: expose project writes and upload endpoint

This commit is contained in:
nac
2026-07-13 02:04:31 +00:00
parent 731a41c652
commit dae2784e2b
6 changed files with 250 additions and 14 deletions
+41 -4
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
@@ -64,6 +64,35 @@ The `owner` parameter on `crm_overview`, `list_customers`, and `list_followups`
Use `crm_filter_options` to discover status/source values and owner candidates before constructing filtered queries. List tools clamp `limit` to `1..100` and normalize negative offsets to `0`.
## 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. Add the API URL and JWT to that private env file before starting or restarting the managed service. HTTP write mode fails at startup with the missing variable name when either credential is absent.
Operational flow:
```bash
NOCOBASE_MCP_UPLOAD_API_BASE_URL='https://nocobase.example/api'
NOCOBASE_MCP_UPLOAD_JWT='<user-jwt>'
bash scripts/mcp_service.sh start
curl -X POST -H "Authorization: Bearer <mcp-token>" -F "file=@quote.pdf" http://<host>:10087/uploads
```
Set the first two values in `.runtime/nocobase-crm-mcp.env` (rather than in client configuration) before the service starts. 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:
@@ -79,7 +108,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
@@ -104,7 +141,7 @@ For a managed user-level systemd service:
bash scripts/mcp_service.sh start
```
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token automatically. Use `new-token` only when rotating the token later.
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token and the non-secret 25 MiB upload size setting automatically. Before the managed HTTP service can start, add `NOCOBASE_MCP_UPLOAD_API_BASE_URL` and `NOCOBASE_MCP_UPLOAD_JWT` to that private file. Use `new-token` only when rotating the token later.
Show the saved URL/header/client config:
+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",
+117 -6
View File
@@ -3,9 +3,13 @@ from __future__ import annotations
import argparse
import hmac
import os
import tempfile
from pathlib import Path
from typing import Annotated, Any
from starlette.applications import Starlette
from starlette.routing import Mount, Route
try:
from pydantic import Field
except ModuleNotFoundError:
@@ -24,6 +28,9 @@ except ModuleNotFoundError:
from .config import load_config
from .crm import CrmService
from .nb_client import NbClient
from .nocobase_http import NocoBaseHttpClient
from .projects import ProjectService
from .uploads import UploadEndpoint
DEFAULT_HTTP_HOST = "0.0.0.0"
@@ -31,6 +38,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:
@@ -93,9 +104,17 @@ def _make_service(config_path: str | Path | None = None) -> CrmService:
return CrmService(config, client)
def _safe_tool_result(operation: Any, /, *args: Any, **kwargs: Any) -> dict[str, Any]:
try:
return operation(*args, **kwargs)
except Exception:
return {"error": {"code": "project_write_failed", "message": "Project write failed"}}
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,
@@ -106,8 +125,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(
@@ -237,6 +257,64 @@ def create_server(
offset=offset,
)
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(
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(
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
@@ -247,8 +325,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(
@@ -264,16 +367,24 @@ 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_client = NbClient(env_name=config.env_name, data_source=config.data_source)
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")
+5 -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
@@ -26,6 +26,7 @@ DEFAULT_NB_ENV = "nb-mcp"
DEFAULT_CONFIG_PATH = "config.json"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
@dataclass(frozen=True)
@@ -87,7 +88,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"
@@ -125,6 +126,7 @@ def write_token_env_file(
env_file.write(f"NOCOBASE_MCP_PATH={path}\n")
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_UPLOAD_MAX_BYTES={DEFAULT_UPLOAD_MAX_BYTES}\n")
env_path.chmod(0o600)
@@ -364,7 +366,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.")
+11
View File
@@ -75,6 +75,17 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
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_render_remote_config_prints_url_and_authorization_header(self) -> None:
output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10"],
+63 -1
View File
@@ -2,7 +2,16 @@ import asyncio
import unittest
from unittest.mock import patch
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):
@@ -59,6 +68,20 @@ class FakeCrmService:
return {"results": []}
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, scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"uploaded"})
class ServerTests(unittest.TestCase):
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
@@ -103,6 +126,45 @@ 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_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_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")