Files
nocobase-mcp/nocobase_crm_mcp/server.py
T
2026-07-08 21:51:08 +00:00

312 lines
12 KiB
Python

from __future__ import annotations
import argparse
import hmac
import os
from pathlib import Path
from typing import Annotated, Any
try:
from pydantic import Field
except ModuleNotFoundError:
def Field(*args: Any, **kwargs: Any) -> Any:
return None
try:
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import TransportSecuritySettings
except ModuleNotFoundError:
FastMCP = None
TransportSecuritySettings = None
from .config import load_config
from .crm import CrmService
from .nb_client import NbClient
DEFAULT_HTTP_HOST = "0.0.0.0"
DEFAULT_HTTP_PORT = 10087
DEFAULT_HTTP_PATH = "/mcp"
DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN"
ENABLE_CONFIG_TOOLS_ENV = "NOCOBASE_MCP_ENABLE_CONFIG_TOOLS"
def _bearer_token(authorization: str) -> str:
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
return ""
return token.strip()
def _authorization_header(scope: dict[str, Any]) -> str:
headers = {key.lower(): value for key, value in scope.get("headers", [])}
return headers.get(b"authorization", b"").decode("latin1")
class BearerTokenAuthMiddleware:
def __init__(self, app: Any, token: str) -> None:
if not token:
raise ValueError("HTTP MCP token must not be empty")
self.app = app
self.token = token
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
if str(scope.get("method") or "").upper() == "OPTIONS":
await send(
{
"type": "http.response.start",
"status": 204,
"headers": [
(b"access-control-allow-origin", b"*"),
(b"access-control-allow-methods", b"POST, OPTIONS"),
(b"access-control-allow-headers", b"authorization, content-type, mcp-session-id"),
],
}
)
await send({"type": "http.response.body", "body": b""})
return
token = _bearer_token(_authorization_header(scope))
if not hmac.compare_digest(token, self.token):
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"www-authenticate", b"Bearer"),
],
}
)
await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
return
await self.app(scope, receive, send)
def _make_service(config_path: str | Path | None = None) -> CrmService:
config = load_config(config_path)
client = NbClient(env_name=config.env_name, data_source=config.data_source)
return CrmService(config, client)
def create_server(
*,
crm_service: CrmService | None = None,
config_path: str | Path | None = None,
readonly: bool = True,
host: str = DEFAULT_HTTP_HOST,
port: int = DEFAULT_HTTP_PORT,
streamable_http_path: str = DEFAULT_HTTP_PATH,
allowed_hosts: list[str] | None = None,
allowed_origins: list[str] | None = None,
) -> 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)
transport_security = None
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
transport_security = TransportSecuritySettings(
allowed_hosts=allowed_hosts or [],
allowed_origins=allowed_origins or [],
)
server = FastMCP(
"nocobase-crm",
host=host,
port=port,
streamable_http_path=streamable_http_path,
transport_security=transport_security,
)
@server.tool()
def crm_health() -> dict[str, Any]:
"""Check NocoBase CLI reachability and the configured CRM collection mapping."""
return service.crm_health()
@server.tool()
def crm_schema(
include_fields: Annotated[
bool,
Field(description="When true, try to include NocoBase collection and field metadata."),
] = False,
) -> dict[str, Any]:
"""Return the configured CRM collection and field mapping for this MCP server."""
return service.crm_schema(include_fields=include_fields)
@server.tool()
def crm_overview(
date_from: Annotated[
str | None,
Field(description="Optional lower bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut."),
] = None,
date_to: Annotated[
str | None,
Field(description="Optional upper bound for follow-up date, in YYYY-MM-DD or an accepted NocoBase date shortcut."),
] = None,
owner: Annotated[
str | None,
Field(description="Optional owner name, username, email, or English name to filter customers and follow-ups."),
] = None,
) -> dict[str, Any]:
"""Return CRM totals, common customer distributions, and recent follow-ups."""
return service.crm_overview(date_from=date_from, date_to=date_to, owner=owner)
@server.tool()
def crm_filter_options(
owner_query: Annotated[
str | None,
Field(description="Optional owner search text. Returns matching user candidates for owner filters."),
] = None,
) -> dict[str, Any]:
"""Return available customer filter values and optional owner candidates."""
return service.crm_filter_options(owner_query=owner_query)
@server.tool()
def resolve_owner(
query: Annotated[str, Field(description="Owner username, nickname, email, or English name to resolve.")],
) -> dict[str, Any]:
"""Return candidate NocoBase users for an owner filter before querying CRM data."""
return service.resolve_owner(query)
if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
@server.tool()
def crm_config_template() -> dict[str, Any]:
"""Infer a config.json template from collection field metadata without reading business records."""
return service.crm_config_template()
@server.tool()
def list_customers(
query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None,
status: Annotated[str | None, Field(description="Optional exact customer status.")] = None,
owner: Annotated[str | None, Field(description="Optional customer owner name, username, email, or English name.")] = None,
source: Annotated[str | None, Field(description="Optional exact customer source.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
) -> dict[str, Any]:
"""List CRM customers with pagination metadata."""
return service.list_customers(query=query, status=status, owner=owner, source=source, limit=limit, offset=offset)
@server.tool()
def get_customer(
customer_id: Annotated[Any, Field(description="Customer primary key value.")],
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = True,
followup_limit: Annotated[int, Field(description="Maximum related follow-ups to return.")] = 10,
) -> dict[str, Any]:
"""Get one customer and optionally include contacts and recent follow-ups."""
return service.get_customer(
customer_id,
include_contacts=include_contacts,
include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
)
@server.tool()
def list_contacts(
customer_id: Annotated[Any | None, Field(description="Optional customer primary key for relation filtering.")] = None,
query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
) -> dict[str, Any]:
"""List CRM contacts, optionally filtered by customer."""
return service.list_contacts(customer_id=customer_id, query=query, limit=limit, offset=offset)
@server.tool()
def list_followups(
customer_id: Annotated[Any | None, Field(description="Optional customer primary key for relation filtering.")] = None,
contact_id: Annotated[Any | None, Field(description="Optional contact primary key for relation filtering.")] = None,
owner: Annotated[str | None, Field(description="Optional follow-up owner name, username, email, or English name.")] = None,
date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None,
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
) -> dict[str, Any]:
"""List customer follow-up records with pagination metadata."""
return service.list_followups(
customer_id=customer_id,
contact_id=contact_id,
owner=owner,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
)
return server
def _read_token_from_env(env_name: str) -> str:
token = os.environ.get(env_name, "").strip()
if not token:
raise RuntimeError(f"{env_name} must be set for HTTP MCP transport")
return token
def create_http_app(server: Any, token: str) -> Any:
return BearerTokenAuthMiddleware(server.streamable_http_app(), token)
def run_http_server(
*,
config_path: Path | None,
host: str,
port: int,
token_env: str,
streamable_http_path: str,
allowed_hosts: list[str] | None,
allowed_origins: list[str] | None,
) -> None:
import uvicorn
token = _read_token_from_env(token_env)
server = create_server(
config_path=config_path,
readonly=True,
host=host,
port=port,
streamable_http_path=streamable_http_path,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
)
app = create_http_app(server, token)
uvicorn.run(app, host=host, port=port, log_level="info")
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only NocoBase CRM MCP server.")
parser.add_argument("--config", default=None, type=Path, help="CRM mapping JSON config path.")
parser.add_argument(
"--transport",
choices=["stdio", "streamable-http"],
default="stdio",
help="MCP transport. HTTP mode requires a bearer token.",
)
parser.add_argument("--host", default=DEFAULT_HTTP_HOST, help="HTTP bind host.")
parser.add_argument("--port", default=DEFAULT_HTTP_PORT, type=int, help="HTTP port.")
parser.add_argument("--path", default=DEFAULT_HTTP_PATH, help="Streamable HTTP MCP path.")
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the HTTP bearer token.")
parser.add_argument("--allowed-host", action="append", dest="allowed_hosts")
parser.add_argument("--allowed-origin", action="append", dest="allowed_origins")
args = parser.parse_args()
if args.transport == "stdio":
create_server(config_path=args.config).run(transport="stdio")
return
run_http_server(
config_path=args.config,
host=args.host,
port=args.port,
token_env=args.token_env,
streamable_http_path=args.path,
allowed_hosts=args.allowed_hosts,
allowed_origins=args.allowed_origins,
)
if __name__ == "__main__":
main()