From dae2784e2b4255577d21eb0b6c5949d0204f2d54 Mon Sep 17 00:00:00 2001 From: nac Date: Mon, 13 Jul 2026 02:04:31 +0000 Subject: [PATCH] feat: expose project writes and upload endpoint --- README.md | 45 +++++++++++-- config.example.json | 13 ++++ nocobase_crm_mcp/server.py | 123 ++++++++++++++++++++++++++++++++-- scripts/manage_mcp_service.py | 8 ++- tests/test_mcp_service.py | 11 +++ tests/test_server.py | 64 +++++++++++++++++- 6 files changed, 250 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5d68b95..c219455 100644 --- a/README.md +++ b/README.md @@ -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='' +bash scripts/mcp_service.sh start +curl -X POST -H "Authorization: Bearer " -F "file=@quote.pdf" http://: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 --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: diff --git a/config.example.json b/config.example.json index 9699f1c..602926b 100644 --- a/config.example.json +++ b/config.example.json @@ -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", diff --git a/nocobase_crm_mcp/server.py b/nocobase_crm_mcp/server.py index 29e9a51..d5350ef 100644 --- a/nocobase_crm_mcp/server.py +++ b/nocobase_crm_mcp/server.py @@ -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") diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index 05ee77b..529e34a 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -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.") diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 163e7f0..758acda 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -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"], diff --git a/tests/test_server.py b/tests/test_server.py index 5908aa1..f52ec74 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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")