312 lines
12 KiB
Python
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()
|