Merge pull request #9 from Donaldttt/feat/typescript-migration
Release / release (push) Canceled after 0s

feat: migrate MCP runtime to TypeScript
This commit is contained in:
Donaldttt
2026-08-15 13:03:44 +08:00
committed by GitHub
83 changed files with 6559 additions and 12057 deletions
+13 -21
View File
@@ -14,27 +14,19 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- uses: actions/setup-node@v4
with:
version: "0.8.8"
enable-cache: true
- name: Set up Python
run: uv python install 3.12
- name: Verify lockfile
run: uv lock --check
node-version: "20.19.5"
cache: npm
- name: Install locked dependencies
run: uv sync --locked --group dev --python 3.12
- name: Lint and formatting
run: |
uv run ruff check nocobase_crm_mcp scripts tests
uv run ruff format --check nocobase_crm_mcp scripts tests
- name: Type check
run: uv run mypy
- name: Compile package
run: uv run --python 3.12 python -m compileall -q nocobase_crm_mcp scripts
run: npm ci
- name: Lint, format, and type check
run: npm run check
- name: Run tests
run: uv run --python 3.12 python -m unittest discover -s tests -v
- name: Build distribution
run: uv build
- name: Audit dependencies
run: uv run pip-audit
run: npm test
- name: Build bundled distribution
run: npm run build
- name: Verify package contents
run: npm pack --dry-run
- name: Audit bundled dependency graph
run: npm audit --audit-level=high
+39
View File
@@ -0,0 +1,39 @@
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20.19.5"
cache: npm
- run: npm ci
- name: Verify release version
run: node scripts/verify-release-version.mjs "$GITHUB_REF_NAME"
- run: npm run check
- run: npm test
- run: npm audit --audit-level=high
- run: npm run build
- name: Pack release and checksums
run: |
mkdir -p release
npm pack --pack-destination release
cd release
sha256sum ./*.tgz > SHA256SUMS
- name: Publish private GitHub Release assets
uses: softprops/action-gh-release@v2
with:
files: |
release/*.tgz
release/SHA256SUMS
generate_release_notes: true
+6 -2
View File
@@ -4,7 +4,7 @@ config.json
config.json*.bak
.runtime/
# Python / build artifacts
# Build artifacts
__pycache__/
*.py[cod]
*$py.class
@@ -16,8 +16,12 @@ htmlcov/
build/
dist/
*.egg-info/
node_modules/
*.tgz
.npm/
release/
# Local environments
# Legacy local environments
.venv/
venv/
+65 -45
View File
@@ -1,97 +1,117 @@
# 安装 NocoBase CRM MCP
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定产品。用户只需要提供本链接、确认一次本机变更,并在隐藏提示中输入自己的 NocoBase API Key。
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在隐藏提示中输入自己的 NocoBase Authorize Key。
如果你是安装 Agent,请直接完成下面的流程,不要把命令转交给用户逐条尝试。只有在需要用户确认安装内容、提供 NocoBase 地址或在隐藏提示中粘贴 Key 时才打断用户。
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、取得 NocoBase URL、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。
## 完成目标
## 完成标准
安装完成时必须同时满足:
- 使用固定 Release `v0.2.0`,不是开发分支;
- 下载本文件所在的同一固定 Release,不使用默认分支;
- MCP 在用户本机以 stdio 运行,默认只读;
- NocoBase 认证属于当前用户自己的个人 Key 和业务角色;
- 认证属于当前用户自己的个人 Authorize Key 和业务角色;
- 当前 MCP 客户端已注册 `nocobase-crm`;
- `crm_health` 返回 `ok=true`。
## 不可违反的安全规则
## 安全规则
- 不索取账号密码,不使用共享服务账号或管理员 Key。
- 不让用户把 Key 发到聊天、命令参数、环境变量或临时文件。
- Key 只能由用户粘贴到 `nb` CLI 的本机隐藏 TTY 提示。
- 不使用 `sudo`,不执行 `curl | sh` / `wget | sh`。
- 写 MCP 客户端配置前必须展示目标路径、创建备份并结构化合并,不能覆盖其他 server。
- 除非用户明确要求且管理员已确认 ACL,否则不启用写入、上传或原始数据。
- 默认不创建后台服务、不监听端口、不暴露到局域网或公网。
- 不让用户把 Key 发到聊天、命令参数、环境变量、剪贴板日志或 MCP 配置。
- Key 只能由用户输入到安装程序的本机隐藏 TTY 提示。
- 不使用 `sudo`,不执行 `curl | sh` 或 `wget | sh`。
- 不静默安装 Git、Node.js、包管理器或其他系统工具。
- 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。
- 默认不启用写入、原始数据、后台服务或网络监听。
## 一次安装流程
## 安装流程
### 1. 确认输入和访问权限
### 1. 确认输入与 Release 权限
从用户或上下文取得:
取得:
- NocoBase 应用 URL,例如 `https://nocobase.example`;
- 普通用户安装目录;
- 当前 Agent/MCP 客户端及其配置路径。
- 当前 Agent/MCP 客户端及其配置路径;
- 普通用户应用数据目录。
本项目暂时通过 Private GitHub 仓库分发。当前 Agent 必须已经连接一个对 `Donaldttt/nocobase-mcp` 有只读权限的 GitHub 账号;无权读取时停止,不得退回 `master`。
从本文件 URL 解析固定 Release Tag。读取同一 Release 的资产列表,只接受:
- `donaldttt-nocobase-crm-mcp-<version>.tgz`;
- `SHA256SUMS`。
仓库为 Private 时,必须使用当前用户已经授权的 GitHub 连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户 GitHub Token 到聊天。
### 2. 一次性说明本机变更
检查 Git、uv、Python 3.12+ 和 NocoBase `nb` CLI 2.1+。若缺失,向用户一次性说明将安装的工具、官方来源和位置,并取得一次确认后再处理。只有缺少 `nb` 时才可能额外需要 Node.js/npm;官方 CLI 包为 `@nocobase/cli`。
检查 `node --version` 和 `npm --version`。要求 Node.js `>=20.11`。
用户安装会留下:固定版本仓库副本、项目 `.venv`、不含 Key 的 `config.json`、`nb` CLI 用户登录环境、MCP 客户端配置和配置备份。不会默认创建 systemd 服务或 HTTP Token。
如果 Node.js 缺失或版本过低,向用户说明需要安装或升级 Node.js、官方来源、目标位置和影响,取得明确同意后再使用操作系统的正规安装方式。不能因为 npm 不存在就直接尝试全局安装。
### 3. 取得固定版本
正常安装只会留下:
安全克隆并验证 Release:
- 应用数据目录中的 npm 包;
- 不含 Key 的 CRM `config.json`;
- 权限受限的本机凭证文件;
- MCP 客户端配置及其备份。
不会安装 Git、Python、uv、`nb CLI`、后台服务或本机监听端口。
### 3. 下载并验证固定发布物
通过已授权的 GitHub API/连接下载两个 Release 资产到新建的临时目录。验证 tarball 的 SHA-256 与 `SHA256SUMS` 完全一致;不一致立即停止并删除未使用的下载文件。
选择用户级安装前缀,例如:
- Windows:`%LOCALAPPDATA%\NocoBaseMCP\app`
- macOS:`~/Library/Application Support/NocoBaseMCP/app`
- Linux:`${XDG_DATA_HOME:-~/.local/share}/nocobase-crm-mcp/app`
执行隔离安装,不使用全局目录:
```bash
git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git <install-dir>
git -C <install-dir> describe --tags --exact-match HEAD
npm install --prefix <install-prefix> --omit=dev <verified-release.tgz>
```
第二条必须精确输出 `v0.2.0`。目标目录已存在但不是本项目、包含未提交修改或版本不明确时停止覆盖。
定位安装后的命令:POSIX 为 `<install-prefix>/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。
### 4. 只运行一个安装命令
### 4. 个人 Key 设置
先引导用户在 NocoBase 个人设置中创建一个可撤销、有合理有效期、绑定本人普通业务角色的个人 API Key。不要让用户把 Key 发给你。
先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的个人 Authorize Key。不要让用户把 Key 发给 Agent。
然后在用户可直接控制输入的本机终端中运行:
在用户可直接控制的本机终端运行:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py \
--project-root <install-dir> install \
--api-base-url <nocobase-url> --env-name nb-mcp --json
<installed-command> setup --api-base-url <nocobase-url> --json
```
这个命令会一次完成运行环境准备、隐藏 Key 登录、配置写入、doctor 检查和通用 MCP 客户端配置生成。用户只在 `nb` 的隐藏提示出现时粘贴 Key;Agent 不读取输入。
安装程序会先验证安全 TTY,再显示隐藏 Key 提示;没有安全 TTY 时会在写文件前停止。Key 通过 `roles:check` 验证成功后才保存,并立即执行 `crm_health`。Key 不会出现在返回 JSON、命令参数、MCP 配置或日志中。
若终端不支持用户私密输入,命令会在写入前停止。此时报告缺少安全 TTY,不得让用户改为在聊天中发送 Key。
POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。不要复制其他用户的凭证文件;每个操作系统用户都必须单独设置自己的 Key。
### 5. 自动完成客户端注册和验证
### 5. 注册并验证 MCP
读取命令返回的 `clientConfig`:
读取 setup 返回的 `clientConfig`:
1. 展示当前客户端配置文件路径和将新增的 `nocobase-crm` 条目;
1. 展示客户端配置路径和新增的 `nocobase-crm` 条目;
2. 创建可恢复备份;
3. 结构化合并该 server,保留所有其他配置;
4. 按客户端能力重载配置或重启客户端;
4. 重载或重启 MCP 客户端;
5. 调用 `crm_health`,要求 `ok=true`。
只有完成以上验证后才能向用户报告成功。最终只报告安装目录、Release、NocoBase 环境名、客户端配置备份位置和启用模式,不显示任何 Token。
最终只报告 Release、安装目录、配置备份位置和只读/写入模式,不显示任何 Key。
## 默认模式与可选写入
## 可选写入
未明确选择时必须保持只读。只有用户明确要求项目创建/更新,且管理员确认该用户角色具有相应 NocoBase ACL 时,才在同一个安装命令末尾加 `--enable-writes`。这仍不会启用附件上传、删除或原始数据。
未明确选择时保持只读。只有用户明确要求项目创建/更新,并且管理员确认该用户角色具有对应 NocoBase ACL 时,才在 setup 以及生成的启动参数中加入 `--enable-writes`。这只增加 `create_project` 和 `update_project`;不增加删除、任意集合访问或附件文件上传接口。
## 故障处理
不要让普通用户手动尝试 `nb` 或拆分安装子命令。由 Agent 根据结构化错误处理;需要详细恢复、更新或高级 HTTP 部署时,阅读同版本的 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。Key 过期或撤销时,重新运行同一个 `install` 命令,让用户在新的隐藏提示中输入新 Key。
- `nocobase_auth_failed`:让用户撤销失效 Key,重新运行同一个 setup 命令并在隐藏提示输入新 Key。
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限 Key。
- Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。
- Private Release 无权访问:报告分发权限问题,不退回 clone 默认分支。
## 用户可发送给 Agent 的最短指令
```text
请按这个协议帮我完成本机安装。默认只读,使用我自己的 NocoBase API Key;只让我在本机隐藏提示中输入 Key,不要让我手动执行安装或 nb 命令。安装完成前必须验证 crm_health:
<固定版本 INSTALL.md 链接>
请按这个固定 Release 的协议帮我完成本机安装。默认只读,使用我自己的 NocoBase Authorize Key;只让我在本机隐藏提示中输入 Key,不要静默安装系统工具。完成前必须验证 crm_health:
<固定 Release 的 INSTALL.md 链接>
```
+52 -267
View File
@@ -1,291 +1,76 @@
# NocoBase CRM MCP
Local MCP server for end users who need customer, contact, follow-up, CRM overview, and tightly scoped project write access in NocoBase.
本项目是在用户电脑本机运行的 NocoBase CRM MCP。每位用户使用自己的个人 Authorize Key,所有查询和写入继续由 NocoBase ACL、字段权限和行级权限裁决;不需要共享账号或远程 MCP 服务。
Each user runs this MCP on their own computer and signs in to the remote NocoBase environment with `nb` CLI. Every query and write therefore uses that user's NocoBase identity and ACL. The project does not require a shared remote MCP service or a shared NocoBase service account.
## 用户侧依赖
For an agent-guided installation that works across MCP clients, give the installing Agent the immutable Release version of [`INSTALL.md`](INSTALL.md). The guide combines adaptive prompts with an idempotent helper script; it defaults to local stdio and read-only access. Do not distribute a default-branch installation link as a release.
正式 Release 是一个已打包的 npm tarball,内部包含 MCP SDK 和运行代码。用户电脑只需要:
The end-user installer creates a project-local `.venv` from the locked runtime dependency set only; it does not install the development tools from the `dev` group. Generated MCP client configuration launches that environment's Python directly, so normal MCP startup does not resync packages or require `uv` to remain in the client command.
- Node.js 20.11 或更高版本(npm 随 Node.js 一起提供);
- 能访问目标 NocoBase;
- 用户自己的个人 Authorize Key。
The default stdio server is read-only. Add `--enable-writes` for fixed-schema project create/update tools. Optional local HTTP mode also provides authenticated attachment uploads. Neither mode exposes arbitrary collections, fields, commands, or delete operations.
用户不再需要 Git、Python、uv、Python 包或 `nb CLI`。安装也不会全局安装本项目,而是放到用户自己的应用数据目录。
## Configure
Private GitHub 仓库的 Release 只能由已经获得仓库或分发渠道权限的用户下载。若要实现面向任意用户的“一条链接安装”,需要另行提供用户 Agent 能认证访问的下载地址。
The supported end-user flow uses a personal NocoBase API Key (Authorize Key), not a username/password or a shared service credential. The installing Agent asks the user to create a revocable Key in NocoBase, then runs the complete guided installation once in a user-controlled local terminal:
## 安装
把对应 Release Tag 下的不可变 [`INSTALL.md`](INSTALL.md) 链接交给安装 Agent。该协议不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定客户端。Agent 会:
1. 检查 Node.js,而不会静默安装系统工具;
2. 下载并校验固定 Release 的 npm tarball;
3. 安装到用户应用数据目录;
4. 引导用户在本机隐藏提示中输入自己的 Authorize Key;
5. 备份并结构化合并 MCP 客户端配置;
6. 调用 `crm_health` 验证完成。
默认 stdio、只读、不监听端口。只有用户明确要求且管理员确认 ACL 时,才给启动参数增加 `--enable-writes`,这只增加固定字段的 `create_project` 和 `update_project`,不提供删除或任意资源工具。
## 本地开发
```bash
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
npm ci
npm run check
npm test
npm run build
node dist/index.js --help
```
The user pastes the Key only into `nb` CLI's private TTY prompt. The helper never accepts the Key as an argument, environment variable, chat value, file, captured output, or return value. Do not copy another user's Key or `nb` configuration directory. The Key must represent that user's normal business identity/role, so NocoBase can apply the intended roles, row-level filters, and field permissions to every operation.
This repository includes a `config.json` generated for the current CRM. To regenerate it from the example:
开发时初始化个人配置:
```bash
cp config.example.json config.json
node dist/index.js setup --api-base-url https://nocobase.example --json
```
The known CRM collection names in this environment are:
Authorize Key 只能输入到隐藏提示。不要把 Key 放进参数、环境变量、聊天、`config.json`、MCP 客户端配置或日志。
- customers: `t_nt3pjadelqi` (`客户`)
- contacts: `t_j77ftx1rf84` (`客户联系人`)
- followups: `t_07667a5evtr` (`跟进记录`)
可用命令:
The checked-in example config uses the confirmed mapping:
- customer owner: `kehu_followup_person` (`跟进人`)
- customer status/progress: `kh_progress` (`进度`)
- customer source: `f_225h6n6v654` (`客户来源`)
- contact-to-customer relation: `kehulianxiren_client`
- follow-up-to-customer relation: `gjjl_client`
- follow-up-to-contact relation: `gjjl_client_contacts`
- actual follow-up participants: `gjjl_followup_person`
- follow-up record submitter: `createdBy` (audit only; not treated as the actual follower)
- follow-up date/content: `f_srysmjkwfq1` and `f_19iplxz0nds`
- teams: `t_team_management`, with `tdgl_manager` and `tdgl_team_members`
- formal employee status: `users.yh_status = nkluuumb1r1`
The default NocoBase CLI env is `nb-mcp`. Override it with:
```bash
export NOCOBASE_MCP_ENV=nb-mcp
export NOCOBASE_MCP_CONFIG=/home/nac/repo/nocobase-mcp/config.json
```text
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
nocobase-crm-mcp doctor [--config <path>] [--json]
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
```
## Local stdio MCP
## 工具
```bash
uv run nocobase-crm-mcp --config config.json
默认提供 17 个只读工具:
- `crm_health`, `crm_schema`, `crm_overview`, `crm_filter_options`
- `resolve_owner`, `list_teams`, `resolve_team`, `get_team`, `team_crm_overview`
- `list_customers`, `get_customer`, `list_contacts`, `list_followups`
- `list_projects`, `get_project`, `get_project_timeline`, `list_stale_customers`
`NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1` 可临时增加 `crm_config_template`。`include_raw=true` 默认被拒绝;管理员审核后才可设置 `NOCOBASE_MCP_ENABLE_RAW_DATA=1`。
## 权限边界
```text
AI 客户端 → 本机 TypeScript MCP → HTTPS NocoBase API → 当前用户 Key 对应的 ACL
```
To expose the two fixed project write tools using the same user's CLI login:
MCP 不缓存共享身份。每个请求都会从当前操作系统用户的私有凭证文件读取 Key,因此撤销或替换 Key 后无需重新生成 MCP 客户端配置。远程地址必须使用 HTTPS(回环地址除外),跨域重定向会在转发 Authorization 前被拒绝,错误内容会脱敏。
```bash
uv run nocobase-crm-mcp --config config.json --enable-writes
```
Exposed tools:
- `crm_health`
- `crm_schema`
- `crm_overview`
- `crm_filter_options`
- `resolve_owner`
- `list_teams`
- `resolve_team`
- `get_team`
- `team_crm_overview`
- `list_customers`
- `get_customer`
- `list_contacts`
- `list_followups`
- `list_projects`
- `get_project`
- `get_project_timeline`
- `list_stale_customers`
`crm_config_template` is hidden by default because it needs NocoBase data-modeling metadata permissions. Enable it only during configuration work:
```bash
NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1 uv run nocobase-crm-mcp --config config.json
```
The `owner` parameter on `crm_overview`, `list_customers`, and `list_followups` accepts a user's username, nickname, email, or English name. The server resolves that value through the `users` resource, then filters CRM relation fields by user id. Customer ownership uses `kehu_followup_person`; follow-up contribution uses `gjjl_followup_person` only. `createdBy` remains visible as the record submitter but is not counted as the actual follower. If a value is missing or ambiguous, the tool returns a structured `error` object with candidate users instead of throwing an MCP error.
Team-aware tools resolve `t_team_management.f_team_name`, then use only members whose `users.yh_status` is the confirmed formal-employee value. Team totals count each follow-up record once; member contribution counts each participant in `gjjl_followup_person`. Team managers, customer owners, participants, and record submitters remain separate in compact results.
Use `crm_filter_options` to discover status/source values and owner candidates before constructing filtered queries. Status/source options are sorted by count descending, with blank source values last.
List tools clamp `limit` to `1..100`, normalize negative offsets to `0`, and implement exact arbitrary offsets even when an offset is not divisible by the limit. Responses include `has_more` and `next_offset`. Compact date-time values use `Asia/Shanghai` and include an explicit `+08:00` offset; date-only project fields stay unchanged.
Nested bounded lists are explicit as well: `get_customer` includes `contacts_pagination` and `recent_followups_pagination`, while `get_project` includes `followups_pagination`. Large analytical scans return a `truncation` object with `total`, `returned`, `limit`, and `next_offset`; aggregation queries fail with a narrowing hint instead of silently discarding buckets. Team member resolution paginates through all related users rather than stopping at 100.
## Response shapes
Every tool returns readable keys, never NocoBase field codes. Relations always
resolve to the same `{"id": ..., "name": ...}` object, so the same customer looks
identical whether it appears in a follow-up row, a contact row, or a customer
detail. Keys whose value is empty are omitted — **an absent key means empty, not
unknown**. Pass `include_raw=true` on any of these tools when the original
NocoBase row is genuinely needed; it is then attached under `raw`.
Raw rows are a separate data-exposure capability and are disabled at the MCP boundary by default. An administrator must explicitly set `NOCOBASE_MCP_ENABLE_RAW_DATA=1` on that user's local installation after reviewing the configured fields and organizational policy. NocoBase ACL still applies, but enabling raw output can send more permitted business fields to the AI client than compact mode.
A `list_followups` row:
```json
{
"id": 40,
"followedAt": "2026-07-08T17:00:00+08:00",
"customers": [{"id": 4249, "name": "Zomato(Eternal)"}],
"contacts": [{"id": 8, "name": "张三"}],
"participants": [{"id": 54, "name": "王小明"}],
"projects": [{"id": 41, "name": "EV inverter"}],
"type": "拜访/请客",
"status": "OK",
"location": "佛山",
"content": "full follow-up text, never truncated"
}
```
`content` is always the complete follow-up text. `type` and `status` are display
labels; `crm_filter_options` publishes the raw codes that the `followup_type` and
`record_status` filters expect. `creator` appears only when the record submitter
is not also one of the participants, since a self-submitted record would
otherwise name the same person twice.
`get_customer` returns `{customer, contacts, recent_followups, timezone}`. The
customer carries `id`, `name`, `english_name`, `territorial_name`, `status`,
`source`, `owners`, `managers`, plus the readable detail fields configured under
`detailFields` (`address`, `intro`, `tags`, `followup_interval_days`). Its
embedded contacts are `{id, name, title}` — phone and email are deliberately
withheld here; call `list_contacts` when they are actually needed, or
`include_details=false` there to omit them.
A customer name rendered as `#4249` means that record has no value in any of its
configured `nameFields`, so only its id could be shown. That is a data-quality
signal, not a name — fix the source record. The server never renders a bare id as
if it were a name.
`nameFields` (customers, contacts, projects) and `labelFields` (users) are
ordered: the first non-empty field wins. Add every field that can hold a name —
omitting one is how a customer ends up displayed as `#4249`.
The current NocoBase model has no structured next-step owner, due date, or completion status. The server therefore does not expose action-item, overdue-action, or generated daily/weekly report tools.
Tool runtime failures are returned as structured errors:
```json
{
"error": {
"code": "nb_command_failed",
"message": "...",
"hint": "...",
"retryable": false
}
}
```
## Project writes and uploads
Local HTTP mode, or stdio with `--enable-writes`, 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.
Attachment upload is disabled by default. After an administrator confirms that the user's NocoBase role may create or update `t_projects`, set `NOCOBASE_MCP_ENABLE_UPLOADS=1` to expose 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 local MCP bearer token.
Every upload first calls NocoBase `roles:check` with the current user's credential. The MCP follows NocoBase's global and collection-specific ACL resolution and requires `t_projects:create` or `t_projects:update` before it calls `attachments:create`. A role without project write permission receives HTTP `403` with `nb_upload_forbidden`, even if that NocoBase installation otherwise allows the generic attachment-create endpoint.
Queries and writes use the saved `nb-mcp` personal Key login. For multipart uploads and attachment associations, the MCP asks `nb env info nb-mcp --json --show-secrets` for the current credential at request time. The credential is held only in memory: it is not placed in `config.json`, the MCP client configuration, logs, or subprocess arguments. If the Key is missing, expired, or revoked, ask the local Agent to rerun the secure `connect-key` flow and enter a newly issued personal Key in the private prompt.
`scripts/mcp_service.sh start` creates `.runtime/nocobase-crm-mcp.env` with mode `0600`, its parent directory with mode `0700`, a random local MCP bearer token, and only non-secret runtime settings. The legacy `NOCOBASE_MCP_API_BASE_URL` / `NOCOBASE_MCP_API_TOKEN` override remains accepted for existing operator deployments, but it is not part of the end-user installation path.
First-install flow:
```bash
# The Agent runs the guided installer in a user-controlled TTY. The user pastes
# a personal NocoBase API Key only into the hidden prompt; it never enters argv.
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
# Create and start the private, loopback-only user service.
bash scripts/mcp_service.sh start
# Optional: after ACL approval, change this generated 0600 file to
# NOCOBASE_MCP_ENABLE_UPLOADS=1, then restart the service.
${EDITOR:-vi} .runtime/nocobase-crm-mcp.env
bash scripts/mcp_service.sh start
curl -X POST -H "Authorization: Bearer <mcp-token>" -F "file=@quote.pdf" http://127.0.0.1:10087/uploads
```
The NocoBase login belongs to the user's own `nb` environment, not `.runtime/nocobase-crm-mcp.env`. The MCP bearer token protects only the loopback HTTP endpoint. An authorized upload that is not yet associated with a project is intentionally retained; this release does not auto-clean orphan attachments. Enable uploads only for approved project writers and monitor unassociated attachments. The MCP provides no delete operation for projects or attachments.
## Permissions
The logged-in user's NocoBase roles need these read permissions:
- CRM collections: `t_nt3pjadelqi`, `t_j77ftx1rf84`, `t_07667a5evtr`
- user lookup collection: `users`, at least `id`, `username`, `nickname`, `email`, `f_yh_english_name`
`crm_schema(include_fields=true)` and the opt-in `crm_config_template` are configuration helpers. They use this read-only NocoBase data-modeling command:
```bash
nb api data-modeling collections fields list --collection-name <collection> --env nb-mcp --yes
```
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.
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 either write-enabled mode, the logged-in user additionally needs:
- `t_projects`: create and update
- `attachments`: upload/create
- `t_projects.f_projects_attachments`: add and remove attachment relations
Keep `NOCOBASE_MCP_ENABLE_UPLOADS=0` for read-only users and project roles without create/update access. Before enabling uploads, test the exact role with both `roles:check` and a real denied/allowed upload case. Do not grant project or attachment delete permissions solely for MCP use; this server does not expose deletes, and operators should handle any retained orphan attachments through an audited administrative cleanup process.
## Internal architecture
The internal module boundaries and compatibility rules are documented in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Local HTTP MCP (optional)
HTTP mode uses streamable HTTP with a separate local bearer token for MCP access:
```bash
export NOCOBASE_MCP_TOKEN='<long-random-token>'
export NOCOBASE_MCP_ENABLE_UPLOADS=0
uv run nocobase-crm-mcp --transport streamable-http --config config.json
```
Default bind is `127.0.0.1:10087` with path `/mcp`. The supported end-user deployment is loopback-only; do not expose this port to a LAN or the public internet.
Local clients must send:
```http
Authorization: Bearer <long-random-token>
```
For a managed user-level systemd service:
```bash
bash scripts/mcp_service.sh start
```
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token automatically. It also discovers the current `nb` executable and saves it as `NOCOBASE_MCP_NB_BIN`, because the systemd user service does not inherit an interactive shell or nvm PATH. Use `new-token` only when rotating the token later.
If `nb` is installed somewhere unusual, set its absolute path before starting:
```bash
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. NocoBase access comes from the same operating-system user's saved `nb-mcp` personal Key. If the MCP reports `nb_auth_failed` or `nb_upload_unauthorized`, ask the local Agent to rerun the secure `connect-key` flow; never paste the Key into chat or a command argument.
Show the saved URL/header/client config:
```bash
bash scripts/mcp_service.sh show-config
```
Check service state, saved paths, saved `nb` executable, URL, and local HTTP probe:
```bash
bash scripts/mcp_service.sh status
```
Stop or uninstall:
```bash
bash scripts/mcp_service.sh stop
bash scripts/mcp_service.sh uninstall
```
The systemd unit references `.runtime/nocobase-crm-mcp.env`; it does not embed the bearer token. The env file is written with `0600` permissions and `.runtime/` is ignored.
内部结构见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md),运维与权限验收见 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.mjs", "*.json", ".github/**/*.yml"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 110
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"suspicious": {
"noExplicitAny": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
}
}
+21 -18
View File
@@ -1,29 +1,32 @@
# Internal architecture
The default data path is:
默认数据路径:
```text
MCP client -> server facade -> CRM domain mixins -> NbClient -> local nb CLI -> NocoBase
MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClient → NocoBase HTTP API
```
The local `nb` environment is the identity boundary. The MCP does not keep a shared NocoBase account, and NocoBase ACL remains authoritative.
## 模块边界
## Package boundaries
- `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。
- `src/credentials.ts` 负责 URL 安全校验、隐藏 TTY 输入和用户级私有凭证文件。Key 不进入 argv、环境变量、CRM 配置或 MCP 配置。
- `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。
- `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。
- `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。
- `scripts/build.mjs` 用 esbuild 将 MCP SDK、Zod 和所有运行代码打成一个 ESM 文件。Release tarball 没有运行时 npm 依赖。
- `crm.py` is the stable `CrmService` facade. Metadata discovery, directory resolution, record queries, presentation, analytics, and reporting live in separate `crm_*` modules.
- `server.py` owns dependency assembly and the public command entrypoint. The four `server_tools_*` modules register the existing MCP contracts; `server_runtime.py` owns HTTP authentication and ASGI assembly; `tool_errors.py` owns structured error mapping.
- `config.py` keeps the public frozen configuration dataclasses and uses one data-driven section loader for camelCase and snake_case JSON aliases.
- `nb_client.py` is the only read/query adapter to the local `nb` CLI. `nocobase_http.py` is limited to HTTP operations the CLI does not expose cleanly, such as multipart upload and attachment relations.
## 身份与网络安全
## Bootstrap and advanced deployment
- 每个请求重新读取当前 OS 用户的凭证文件,NocoBase ACL 是最终权限边界。
- 非回环 HTTP URL 被拒绝;远程 Key 只能发往 HTTPS。
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
- Token 会从 HTTP 错误中替换为 `<redacted>`。
- stdio 是唯一默认传输,不创建后台服务或监听端口。
- `scripts/agent_install.py` is a compatibility entrypoint. Its standard-library-only implementation lives in `scripts/agent_installer/`; `flow.py` composes the public one-command `install` journey, while the lower-level inspect, prepare, authentication, diagnostics, and client-config functions remain independently testable recovery primitives.
- `scripts/manage_mcp_service.py` is the compatibility entrypoint for optional Linux loopback HTTP mode. Configuration serialization, runtime operations, and CLI parsing live in separate `mcp_service_*` modules.
## 兼容规则
Default installation remains stdio, read-only, without a background service, listener port, uploads, raw rows, or shared credentials.
## Compatibility rules
- Keep the `nocobase-crm-mcp` console entrypoint and `CrmService` import stable.
- Treat MCP tool names, input schemas, response shapes, configuration aliases, installer commands, and service-manager commands as public contracts.
- Personal API Keys may only enter the `nb` CLI private TTY prompt. Never move them through Agent chat, argv, environment variables, logs, or temporary files.
- 保持 17 个只读工具名称、参数和主要响应形状稳定。
- 写模式只增加 `create_project`、`update_project`,不提供 delete/destroy。
- `include_raw` 默认在 MCP 边界拒绝。
- 配置继续接受 camelCase 与 snake_case 字段别名。
- Release Tag、`package.json` 和 `install-manifest.json` 的版本必须一致。
+32 -140
View File
@@ -1,164 +1,56 @@
# 本机交付与登录手册
# 本机交付与权限验收
## 交付模型
每位用户在自己的电脑运行 MCP,并使用绑定本人普通业务身份/角色的个人 API Key 登录 `nb` CLI:
每位用户在自己的电脑运行 MCP,并使用绑定本人业务身份的个人 Authorize Key:
```text
本机 AI 客户端 → 本机 NocoBase CRM MCP → 本机 nb CLI 登录态 → 远程 NocoBase ACL
本机 AI 客户端 → 本机 TypeScript MCP → 远程 NocoBase API → 本人 ACL
```
MCP 不保存公共业务账号,也不需要部署一个多人共用的远程 MCP 服务。NocoBase 仍是最终权限裁决者;同一个工具由不同用户调用时,只能返回或修改其账号 ACL 允许的数据。
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。
## 环境要求
## Release 发布物
- Python 3.12 或更高版本
- Git(仅安装、版本校验和更新)
- `uv`
- NocoBase `nb` CLI 2.1 或更高版本
- Node.js/npm(仅在尚未安装 `nb` 时用于安装官方 CLI)
- 可通过浏览器访问目标 NocoBase
- 管理员已为用户配置所需的 NocoBase 角色与数据权限
Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查、测试、bundle、npm pack 和生产依赖审计,并上传:
## 推荐:链接驱动的 Agent 安装
- npm tarball;
- `SHA256SUMS`。
正式发布后,优先把对应 Release Tag 下的 [`INSTALL.md`](../INSTALL.md) 不可变链接交给用户。该入口不是 Codex 专用插件:任何能够读取链接、执行本机终端并结构化修改 MCP 客户端配置的 Agent,都可以按协议完成安装。确定性工作由 `scripts/agent_install.py` 处理;Agent 负责识别操作系统与客户端、解释变更、备份并合并客户端配置;用户只在 NocoBase 获取个人 Key,并把它粘贴到本机隐藏提示。
源码仓库保持 Private 时,外部用户必须具有 Release 下载权限,或通过另一个经过身份认证的分发渠道取得相同资产。Private GitHub 链接本身不能成为任意用户可访问的一键安装链接。
发布方必须保证安装 Agent 能读取链接和相同版本的代码。当前仓库为 private 时,用户需要已经连接有仓库权限的 GitHub 账号;面向普通外部用户的一条链接安装,则必须公开该版本的 `INSTALL.md` 与发布物,或放到用户 Agent 可认证访问的分发渠道。不要把开发分支链接当作交付链接。
## 用户电脑上的内容
这个通用流程默认使用 stdio,因此不需要后台服务或本机 MCP Bearer Token。Linux systemd/HTTP 方式只用于确有附件上传需求的高级场景。
- Node.js 20.11+(唯一系统运行时);
- 用户应用数据目录中的打包 MCP;
- CRM 映射配置;
- 当前 OS 用户的私有凭证文件;
- MCP 客户端配置和备份。
## 首次安装与统一引导
```bash
git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git
cd nocobase-mcp
```
正式终端用户不应手动尝试依赖同步或 `nb env add/auth`。安装 Agent 在用户可直接控制的本机终端中只运行统一安装命令:
```bash
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
```
该命令依次准备运行环境、连接个人 Key、运行 doctor,并返回通用 `clientConfig`。Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。
先进行健康检查:
```bash
.venv/bin/python -m nocobase_crm_mcp.server --config config.json
```
然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,由 Agent 重新启动 `connect-key`,用户在新的本机隐藏提示中输入新 Key;不要让用户手动尝试 `nb` 命令。
## MCP 客户端配置
推荐使用 stdio。把绝对路径替换成用户电脑上的实际安装目录:
```json
{
"mcpServers": {
"nocobase-crm": {
"command": "/absolute/path/to/nocobase-mcp/.venv/bin/python",
"args": [
"-m",
"nocobase_crm_mcp.server",
"--config",
"/absolute/path/to/nocobase-mcp/config.json"
],
"env": {
"NOCOBASE_MCP_NB_BIN": "/absolute/path/to/nb"
}
}
}
}
```
不要手写猜测虚拟环境路径;优先使用统一 `install` 结果中的 `clientConfig`。上例是 POSIX 路径,Windows 输出会自动使用 `.venv\\Scripts\\python.exe`。`inspect`、`prepare`、`connect-key`、`client-config` 和 `doctor` 子命令仅为排障和旧流程兼容保留,不应让普通用户逐条运行。
默认只读。如需项目创建和更新,在参数末尾加入 `--enable-writes`。这些写工具只允许预先定义的项目字段,不提供删除或任意资源访问能力。
附件上传需要可选的本机 HTTP 模式。HTTP 服务默认只监听 `127.0.0.1:10087`;上传路由本身也默认关闭,不得暴露到局域网或公网:
```bash
NOCOBASE_MCP_TOKEN='<随机长字符串>' \
NOCOBASE_MCP_ENABLE_UPLOADS=0 \
uv run nocobase-crm-mcp --transport streamable-http --config config.json
```
Linux 用户也可以执行 `bash scripts/mcp_service.sh start` 安装用户级 systemd 服务。
只有管理员确认该用户角色拥有 `t_projects` 的 create 或 update 后,才能在 `.runtime/nocobase-crm-mcp.env` 中设置 `NOCOBASE_MCP_ENABLE_UPLOADS=1` 并重新执行 `bash scripts/mcp_service.sh start`。即使已显式启用,MCP 仍会在每次上传前调用当前账号的 `roles:check`;无项目写权限时返回 `403 / nb_upload_forbidden`,不会调用 `attachments:create`。
不需要 Git、Python、uv、`nb CLI` 或项目源码 checkout。npm 只在隔离前缀安装一个已经 bundle 的包,运行时没有额外 npm 依赖。
## 权限验收
交付前至少使用两个权限不同的测试账号验证:
至少使用两个权限不同的真实测试账号:
1. 两台电脑或两个隔离的操作系统账号分别通过 Agent 的 `connect-key` 流程录入各自个人 Key。
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups` 和 `list_projects`。
3. 确认各自只能看到 NocoBase UI 中本人有权查看的数据。
4. 对无项目写权限的账号调用 `create_project`,确认 NocoBase 拒绝。
5. 显式启用上传后,对无项目写权限的账号上传一个小文件,确认返回 `nb_upload_forbidden`,并用管理员确认附件记录数没有增加。
6. 对有写权限的账号创建测试项目并完成上传、关联和移除,确认审计字段记录的是该用户,而不是共享服务账号。
7. 退出或使 Token 过期后,确认 MCP 返回 `nb_auth_failed`,且不会降级使用其他凭证。
1. 分别签发个人 Key,并在隔离的 OS 用户/凭证路径运行 setup。
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups`、`list_projects`。
3. 确认返回范围与各自在 NocoBase UI 中的 ACL 一致。
4. 无项目写权限账号在 `--enable-writes` 下调用 `create_project`,确认 NocoBase 拒绝。
5. 有权限账号创建和更新测试项目,确认审计身份属于该用户。
6. 撤销 Key 后确认 MCP 返回 `nocobase_auth_failed`,且不会降级使用其他凭证。
7. 检查错误与日志,确认没有 Key、Authorization header 或业务原始行泄漏。
修改测试账号角色后,如果 `roles:check` 仍返回旧角色,先由管理员清理服务端角色缓存,再由 Agent 使用新签发的个人 Key 重新运行 `connect-key`。不应通过共享 Token 绕过。
## 更新与回滚
## 凭证安全
Agent 下载新的固定 Release tarball并验证 SHA-256,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev <tarball>`。安装前备份 MCP 客户端配置和非秘密 CRM 配置。
- 个人 API Key 由 `nb` CLI 保存,不能写入 `config.json`、MCP 客户端配置、聊天、Shell 历史或命令参数。
- MCP 获取附件请求凭证时,Token 只存在于进程内存,不进入子进程参数和错误日志。
- `.runtime/` 只保存本机 HTTP MCP 的独立 Bearer Token,并已被 Git 忽略。
- 不要共享操作系统账号运行 MCP;本机权限隔离依赖每位用户拥有独立的 `nb` 登录存储。
- `NOCOBASE_MCP_API_TOKEN` 只为旧部署兼容保留,不用于终端用户交付。
- `include_raw=true` 默认被 MCP 拒绝。只有在管理员审核 `config.json` 字段和 AI 数据使用政策后,才可在该用户本机设置 `NOCOBASE_MCP_ENABLE_RAW_DATA=1`。
- `POST /uploads` 默认不存在。只有在管理员完成角色验收后才设置 `NOCOBASE_MCP_ENABLE_UPLOADS=1`;MCP 的项目写权限门禁是纵深防御,NocoBase ACL 仍是最终权限边界。
## 更新、回滚与恢复
更新前记录当前版本,更新后重新同步锁定依赖并运行测试:
```bash
git rev-parse HEAD
git pull --ff-only
uv sync --locked --no-dev
.venv/bin/python -m unittest discover -s tests
```
使用 systemd 本机 HTTP 服务时,再执行:
```bash
bash scripts/mcp_service.sh start
bash scripts/mcp_service.sh status
journalctl --user -u nocobase-crm-mcp.service -n 200 --no-pager
```
如果新版本验收失败,切回上一步记录的已发布 Tag 或 Commit,然后重新同步并启动:
```bash
git switch --detach <previous-release-tag-or-commit>
uv sync --locked --no-dev
bash scripts/mcp_service.sh start
```
发布交付必须来自工作区干净、CI 全绿的带注释 Git Tag;不要从开发分支任意提交直接打包给用户。
需要备份的业务配置是 `config.json`。它不应包含 Token,可以进入受控版本库或企业配置备份。`.runtime/nocobase-crm-mcp.env` 只包含本机 HTTP Token 和运行参数;丢失后运行 `bash scripts/mcp_service.sh new-token` 重新生成即可。不要备份、复制或分发用户的个人 Key;恢复电脑后重新签发 Key,并由 Agent 运行 `connect-key`。
建议监控:
- MCP 客户端定期调用 `crm_health`。
- 本机 HTTP 用户执行 `bash scripts/mcp_service.sh status`,确认 systemd active 且 HTTP probe reachable。
- 日志只检查错误码和命令退出状态;发现业务原始数据或 Token 时立即停止交付并按安全事件处理。
回滚时重新安装上一固定 Release 的已验证 tarball。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 Key。
## 常见故障
常见故障:
- `nb_not_found`:确认 `nb` 在 PATH 中,或设置 `NOCOBASE_MCP_NB_BIN` 为绝对路径。
- `nb_auth_failed`:由 Agent 重启 `connect-key`,用户只在本机隐藏提示中输入新 Key。
- `NO_PERMISSIONS`:由 NocoBase 管理员调整该用户的角色、字段或行级数据权限。
- 附件返回 `nb_upload_unauthorized`:重新登录后重试;不要把其他用户的 Token 写入环境变量。
- 附件返回 `nb_upload_forbidden`:当前角色没有项目 create/update;不要为了绕过门禁而改用共享高权限账号。
- 环境连接错误:执行 `nb env info nb-mcp`,确认 `apiBaseUrl` 指向目标环境的 `/api`。
- `nocobase_auth_failed`:重新运行 setup,让用户在隐藏提示输入新签发 Key。
- `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
- 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。
- 跨源重定向错误:修正 NocoBase API URL或代理配置;不要关闭同源保护。
+29 -29
View File
@@ -1,59 +1,59 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"id": "com.nocobase.crm-mcp",
"name": "NocoBase CRM MCP",
"version": "0.2.0",
"version": "0.3.0",
"distribution": {
"repository": "https://github.com/Donaldttt/nocobase-mcp.git",
"releaseRef": "v0.2.0",
"guide": "https://github.com/Donaldttt/nocobase-mcp/blob/v0.2.0/INSTALL.md",
"rawGuide": "https://raw.githubusercontent.com/Donaldttt/nocobase-mcp/v0.2.0/INSTALL.md",
"accessRequirement": "The installing agent must be able to read the guide and clone the exact release ref. A private repository requires an authenticated GitHub connection."
"repository": "https://github.com/Donaldttt/nocobase-mcp",
"releaseRef": "must-match-v<version>",
"guide": "INSTALL.md from the exact Release tag",
"assets": ["donaldttt-nocobase-crm-mcp-<version>.tgz", "SHA256SUMS"],
"accessRequirement": "Private Release download requires an authenticated user or Agent with explicit repository access."
},
"runtime": {
"location": "user-device",
"defaultTransport": "stdio",
"python": ">=3.12",
"packageManager": "uv",
"environment": ".venv",
"launcher": "project-venv-python",
"dependencyProfile": "runtime-only-no-dev",
"nocobaseCli": ">=2.1"
"node": ">=20.11",
"packageFormat": "bundled-npm-tarball",
"runtimeNpmDependencies": 0,
"nocobaseCli": false,
"python": false
},
"installation": {
"requiredTools": ["git", "uv", "python", "nb"],
"conditionalTools": {
"nodeAndNpm": "Required only when the NocoBase nb CLI is not already installed."
},
"requiredTools": ["node", "npm"],
"sourceCheckout": false,
"globalPackageInstall": false,
"persistentArtifacts": [
"release checkout",
"project .venv",
"config.json",
"nb CLI user environment",
"isolated user-data npm prefix",
"non-secret CRM config",
"private per-user credential file",
"MCP client configuration and backup"
],
"defaultBackgroundService": false
},
"authentication": {
"type": "personal-api-key",
"cliAuthType": "token",
"secretEntry": "user-controlled-private-tty",
"type": "personal-authorize-key",
"secretEntry": "user-controlled-hidden-tty",
"storage": "per-user private local credential file",
"posixFileMode": "0600",
"allowPassword": false,
"allowAgentChatSecret": false,
"allowCommandLineSecret": false
"allowCommandLineSecret": false,
"allowEnvironmentSecret": false
},
"entrypoints": {
"guide": "INSTALL.md",
"helper": "scripts/agent_install.py",
"guidedCommand": "install",
"binary": "nocobase-crm-mcp",
"guidedCommand": "setup",
"configTemplate": "config.example.json"
},
"securityDefaults": {
"readOnly": true,
"writes": false,
"uploads": false,
"rawData": false,
"sharedNocobaseCredential": false,
"networkBind": "none-stdio"
"networkBind": "none-stdio",
"remoteHttpRequiresTls": true,
"crossOriginAuthRedirect": false
}
}
-3
View File
@@ -1,3 +0,0 @@
"""Read-only MCP access to a NocoBase CRM."""
__version__ = "0.1.0"
-306
View File
@@ -1,306 +0,0 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field, fields, replace
from pathlib import Path
from typing import Any
PROJECT_PROGRESS_VALUES = {
"询价阶段": "fk9xj8akn46",
"已报价": "k4w2vcz819w",
"送样": "g95vqeetu83",
"已定点": "fe0peg505d7",
"SOP": "a9t9awhtt3r",
"终止": "a9t9awhtsfasg3r",
}
# Output key -> NocoBase field code, used to render `get_customer` detail with
# human-readable names instead of internal field codes. Entries whose field is
# absent from the configured `fields` list are simply skipped.
CUSTOMER_DETAIL_FIELDS = {
"address": "f_y8iyf2jhana",
"intro": "f_spgoie0arjq",
"tags": "kh_tags",
"followup_interval_days": "f_kyf51uh0ky7",
}
@dataclass(frozen=True)
class CustomerConfig:
resource: str = "customers"
id_field: str = "id"
fields: list[str] | None = field(
default_factory=lambda: ["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"]
)
search_fields: list[str] | None = field(default_factory=lambda: ["name", "code"])
name_fields: list[str] | None = field(
default_factory=lambda: [
"f_q2q60q7bqbg",
"f_kh_english_name",
"f_territorial_name",
"name",
"title",
"code",
]
)
english_name_field: str = "f_kh_english_name"
territorial_name_field: str = "f_territorial_name"
status_field: str = "status"
source_field: str = "source"
owner_field: str = "owner"
manager_field: str = "kehu_manage_person"
detail_fields: dict[str, str] = field(default_factory=lambda: CUSTOMER_DETAIL_FIELDS.copy())
sort: list[str] | None = field(default_factory=lambda: ["-updatedAt"])
@dataclass(frozen=True)
class ContactConfig:
resource: str = "contacts"
id_field: str = "id"
fields: list[str] | None = field(
default_factory=lambda: ["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"]
)
search_fields: list[str] | None = field(default_factory=lambda: ["name", "phone", "email"])
name_fields: list[str] | None = field(default_factory=lambda: ["f_2zpg91gtmwp", "name", "title"])
title_field: str = "f_tn8zdm2f938"
phone_field: str = "khlxr_phone_number"
email_field: str = "khlxr_email"
customer_field: str = "customer"
sort: list[str] | None = field(default_factory=lambda: ["-updatedAt"])
@dataclass(frozen=True)
class FollowupConfig:
resource: str = "followups"
id_field: str = "id"
fields: list[str] | None = field(
default_factory=lambda: [
"id",
"customer",
"contact",
"owner",
"content",
"followedAt",
"nextFollowAt",
"createdAt",
"updatedAt",
]
)
search_fields: list[str] | None = field(default_factory=lambda: ["content"])
customer_field: str = "customer"
contact_field: str = "contact"
owner_field: str = "owner"
owner_fields: list[str] | None = None
date_field: str = "followedAt"
content_field: str = "f_19iplxz0nds"
type_field: str = "gjjl_followup_type"
status_field: str = "f_hskl7gjvos9"
project_field: str = "f_gjjl_projects"
location_field: str = "f_b5c4hfi5og6"
sort: list[str] | None = field(default_factory=lambda: ["-followedAt", "-createdAt"])
def __post_init__(self) -> None:
if self.owner_fields is None:
object.__setattr__(self, "owner_fields", [self.owner_field] if self.owner_field else [])
@dataclass(frozen=True)
class UserConfig:
resource: str = "users"
id_field: str = "id"
fields: list[str] | None = field(
default_factory=lambda: ["id", "username", "nickname", "email", "f_yh_english_name"]
)
match_fields: list[str] | None = field(
default_factory=lambda: ["username", "nickname", "email", "f_yh_english_name"]
)
label_fields: list[str] | None = field(
default_factory=lambda: ["nickname", "f_yh_english_name", "username", "email"]
)
@dataclass(frozen=True)
class TeamConfig:
resource: str = "t_team_management"
id_field: str = "id"
name_field: str = "f_team_name"
manager_field: str = "tdgl_manager"
member_field: str = "tdgl_team_members"
user_status_field: str = "yh_status"
formal_user_status_value: str = "nkluuumb1r1"
@dataclass(frozen=True)
class ProjectConfig:
resource: str = "t_projects"
id_field: str = "id"
name_field: str = "f_project_name"
name_fields: list[str] | None = None
customer_field: str = "client_ID"
customer_relation_field: str = "f_projects_client"
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"
contact_field: str = "f_projects_contacts"
followup_field: str = "f_projects_followup_records"
update_field: str = "f_u28gm9h2uzl"
update_resource: str = "t_qxeepjvi39s"
update_project_field: str = "f_k37rbpi4vye"
update_date_field: str = "f_6g9fw5oqef9"
update_content_field: str = "f_ei16g0ihwwd"
update_attachment_field: str = "f_6pk5pv17i27"
progress_values: dict[str, str] = field(default_factory=lambda: PROJECT_PROGRESS_VALUES.copy())
def __post_init__(self) -> None:
if self.name_fields is None:
object.__setattr__(self, "name_fields", [self.name_field, "name", "title"])
@dataclass(frozen=True)
class CrmConfig:
env_name: str = "nb-mcp"
data_source: str = "main"
timezone: str = "Asia/Shanghai"
customer: CustomerConfig = field(default_factory=CustomerConfig)
contact: ContactConfig = field(default_factory=ContactConfig)
followup: FollowupConfig = field(default_factory=FollowupConfig)
user: UserConfig = field(default_factory=UserConfig)
team: TeamConfig = field(default_factory=TeamConfig)
project: ProjectConfig = field(default_factory=ProjectConfig)
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError(f"CRM config must be a JSON object: {path}")
return data
def _string_list(value: Any) -> list[str] | None:
if value is None:
return None
if not isinstance(value, list):
raise ValueError("Expected a JSON array of strings")
return [str(item) for item in value]
def _string_map(value: Any) -> dict[str, str] | None:
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("Expected a JSON object mapping strings to strings")
return {str(key): str(item) for key, item in value.items()}
def _pick(data: dict[str, Any], *names: str) -> Any:
for name in names:
if name in data:
return data[name]
return None
def _camel_case(name: str) -> str:
head, *tail = name.split("_")
return head + "".join(part.capitalize() for part in tail)
def _convert_like(default: Any, value: Any) -> Any:
if isinstance(default, list):
return _string_list(value)
if isinstance(default, dict):
return _string_map(value)
return str(value)
def _section_overrides(base: Any, data: dict[str, Any], *, exclude: set[str] | None = None) -> dict[str, Any]:
excluded = exclude or set()
overrides: dict[str, Any] = {}
for config_field in fields(base):
name = config_field.name
if name in excluded:
continue
value = _pick(data, _camel_case(name), name)
if not value:
continue
overrides[name] = _convert_like(getattr(base, name), value)
return overrides
def _simple_section[SectionConfig](config_type: type[SectionConfig], data: dict[str, Any] | None) -> SectionConfig:
base = config_type()
if not data:
return base
return replace(base, **_section_overrides(base, data)) # type: ignore[type-var]
def _customer_config(data: dict[str, Any] | None) -> CustomerConfig:
return _simple_section(CustomerConfig, data)
def _contact_config(data: dict[str, Any] | None) -> ContactConfig:
return _simple_section(ContactConfig, data)
def _followup_config(data: dict[str, Any] | None) -> FollowupConfig:
if not data:
return FollowupConfig()
base = FollowupConfig()
overrides = _section_overrides(base, data, exclude={"owner_fields"})
owner_field = str(overrides.get("owner_field", base.owner_field))
overrides["owner_fields"] = _string_list(_pick(data, "ownerFields", "owner_fields")) or (
[owner_field] if owner_field else []
)
return replace(base, **overrides)
def _user_config(data: dict[str, Any] | None) -> UserConfig:
return _simple_section(UserConfig, data)
def _team_config(data: dict[str, Any] | None) -> TeamConfig:
return _simple_section(TeamConfig, data)
def _project_config(data: dict[str, Any] | None) -> ProjectConfig:
if not data:
return ProjectConfig()
base = ProjectConfig()
overrides = _section_overrides(base, data, exclude={"name_fields", "progress_values"})
# Keep this as None when unset so __post_init__ derives it from a custom name_field.
overrides["name_fields"] = _string_list(_pick(data, "nameFields", "name_fields"))
return replace(base, **overrides)
def load_config(path: str | Path | None = None) -> CrmConfig:
configured_path = path or os.environ.get("NOCOBASE_MCP_CONFIG")
raw: dict[str, Any] = {}
if configured_path:
raw = _load_json(Path(configured_path).expanduser())
collections = raw.get("collections") or {}
if not isinstance(collections, dict):
raise ValueError("CRM config collections must be a JSON object")
env_name = os.environ.get("NOCOBASE_MCP_ENV") or raw.get("env") or raw.get("envName") or "nb-mcp"
data_source = (
os.environ.get("NOCOBASE_MCP_DATA_SOURCE") or raw.get("dataSource") or raw.get("data_source") or "main"
)
timezone = os.environ.get("NOCOBASE_MCP_TIMEZONE") or raw.get("timezone") or "Asia/Shanghai"
return CrmConfig(
env_name=str(env_name),
data_source=str(data_source),
timezone=str(timezone),
customer=_customer_config(collections.get("customers")),
contact=_contact_config(collections.get("contacts")),
followup=_followup_config(collections.get("followups")),
user=_user_config(raw.get("users") or raw.get("user")),
team=_team_config(raw.get("teams") or raw.get("team")),
project=_project_config(raw.get("projects")),
)
-25
View File
@@ -1,25 +0,0 @@
from __future__ import annotations
from .crm_analytics import CrmAnalyticsMixin
from .crm_core import CrmCoreMixin
from .crm_directory import CrmDirectoryMixin
from .crm_metadata import CrmMetadataMixin
from .crm_presenters import CrmPresentersMixin
from .crm_records import CrmRecordsMixin
from .crm_reporting import CrmReportingMixin
from .crm_shared import PROJECTION_FALLBACK_WARNING, _prune
class CrmService(
CrmCoreMixin,
CrmMetadataMixin,
CrmDirectoryMixin,
CrmRecordsMixin,
CrmPresentersMixin,
CrmAnalyticsMixin,
CrmReportingMixin,
):
"""Facade for CRM metadata, directory, record, and analytics operations."""
__all__ = ["CrmService", "PROJECTION_FALLBACK_WARNING", "_prune"]
-183
View File
@@ -1,183 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_schema_helpers import _field_path
from .crm_shared import (
CUSTOMER_STATUS_LABELS,
FOLLOWUP_STATUS_LABELS,
FOLLOWUP_TYPE_LABELS,
MAX_PAGE_SIZE,
MAX_SCAN_RECORDS,
Any,
Mapping,
NbCommandError,
_decorate_dimension_counts,
_dimension_value,
_first_numeric,
build_and_filter,
build_date_range_condition,
build_relation_id_condition,
)
class CrmAnalyticsMixin(CrmMixin):
def crm_overview(
self,
date_from: str | None = None,
date_to: str | None = None,
owner: str | None = None,
) -> dict[str, Any]:
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
customer_filter = build_and_filter([build_relation_id_condition(self.config.customer.owner_field, owner_id)])
followup_filter = build_and_filter(
[
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
totals = {
"customers": self._count(self.config.customer.resource, self.config.customer.id_field, customer_filter),
"contacts": self._count(self.config.contact.resource, self.config.contact.id_field, {}),
"followups": self._count(self.config.followup.resource, self.config.followup.id_field, followup_filter),
}
overview: dict[str, Any] = {
"totals": totals,
"customer_status": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.status_field,
customer_filter,
),
"customer_source": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.source_field,
customer_filter,
),
"customer_owner": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
[self.config.customer.owner_field, "id"],
customer_filter,
),
"recent_followups": self._compact_followups(
self._data(
self.client.list_records(
self.config.followup.resource,
filter=followup_filter,
fields=self._followup_compact_fields(),
sort=self.config.followup.sort,
page=1,
page_size=10,
)
)
),
}
overview["customer_status"] = self._decorate_status_counts(overview["customer_status"])
overview["customer_source"] = self._decorate_source_counts(overview["customer_source"])
overview["customer_owner"] = self._decorate_owner_counts(overview["customer_owner"])
return overview
def crm_filter_options(self, owner_query: str | None = None) -> dict[str, Any]:
owner_candidates: list[dict[str, Any]] = []
if (owner_query or "").strip():
owner_candidates = self.resolve_owner(owner_query or "")["results"]
return {
"customer_status": self._decorate_status_counts(
self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.status_field,
{},
)
),
"customer_source": self._decorate_source_counts(
self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.source_field,
{},
)
),
# Follow-up rows render type and status as labels only, so the raw
# codes the filters expect are published here instead.
"followup_type": [{"value": value, "label": label} for value, label in FOLLOWUP_TYPE_LABELS.items()],
"followup_record_status": [
{"value": value, "label": label} for value, label in FOLLOWUP_STATUS_LABELS.items()
],
"owner_candidates": owner_candidates,
"max_limit": MAX_PAGE_SIZE,
}
def _count(self, resource: str, id_field: str, filter_value: Mapping[str, Any]) -> int:
response = self.client.query_records(
resource,
measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}],
filter=filter_value,
limit=1,
)
return _first_numeric(self._data(response), "count")
def _group_count(
self,
resource: str,
id_field: str,
dimension_field: str | list[str],
filter_value: Mapping[str, Any],
) -> list[dict[str, Any]]:
if not dimension_field:
return []
dimension_path = _field_path(dimension_field)
dimension_alias = "_".join(dimension_path)
rows: list[dict[str, Any]] = []
batch_size = MAX_PAGE_SIZE
offset = 0
while True:
response = self.client.query_records(
resource,
dimensions=[{"field": dimension_path, "alias": dimension_alias}],
measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}],
orders=[{"field": dimension_path, "alias": dimension_alias, "order": "asc"}],
filter=filter_value,
limit=batch_size,
offset=offset,
)
batch = self._data(response)
rows.extend(batch)
if len(batch) < batch_size:
break
if len(rows) >= MAX_SCAN_RECORDS:
raise NbCommandError(
f"Aggregation produced at least {MAX_SCAN_RECORDS} buckets; add filters to narrow the result"
)
offset += batch_size
return rows
def _decorate_status_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _decorate_dimension_counts(
rows,
preferred_keys=[self.config.customer.status_field, "kh_progress", "status"],
labels=CUSTOMER_STATUS_LABELS,
)
def _decorate_source_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _decorate_dimension_counts(
rows,
preferred_keys=[self.config.customer.source_field, "f_225h6n6v654", "source"],
labels={},
)
def _decorate_owner_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
owner_alias = "_".join(_field_path([self.config.customer.owner_field, "id"]))
decorated: list[dict[str, Any]] = []
for row in rows:
value, key = _dimension_value(row, [owner_alias, "owner_id", "kehu_followup_person_id"])
item = dict(row)
if value is not None:
if key is not None and key not in {"owner_id", "value"}:
item.pop(key, None)
item["owner_id"] = value
decorated.append(item)
return decorated
-74
View File
@@ -1,74 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
PROJECTION_FALLBACK_WARNING,
Any,
CrmConfig,
LabelSpec,
NbClient,
NbCommandError,
_fields_with_projections,
_unique_strings,
)
class CrmCoreMixin(CrmMixin):
def __init__(self, config: CrmConfig, client: NbClient) -> None:
self.config = config
self.client = client
def _customer_spec(self) -> LabelSpec:
return LabelSpec(self.config.customer.id_field, tuple(self.config.customer.name_fields or ()))
def _contact_spec(self) -> LabelSpec:
return LabelSpec(self.config.contact.id_field, tuple(self.config.contact.name_fields or ()))
def _user_spec(self) -> LabelSpec:
return LabelSpec(self.config.user.id_field, tuple(self.config.user.label_fields or ()))
def _project_spec(self) -> LabelSpec:
return LabelSpec(self.config.project.id_field, tuple(self.config.project.name_fields or ()))
def _team_fields(self) -> list[str]:
# Only the related user ids are needed: _team_users re-queries the users
# collection for their details, and _compact_team just counts members.
team = self.config.team
id_only = LabelSpec(self.config.user.id_field, ())
return _fields_with_projections(
[team.id_field, team.name_field],
[
*self._projection(team.manager_field, id_only, None),
*self._projection(team.member_field, id_only, None),
],
)
def _projection(self, relation: str | None, spec: LabelSpec, configured_fields: list[str] | None) -> list[str]:
"""Build dotted field paths that fetch only a relation's id and name.
Without this NocoBase expands every related row in full -- junction table
rows, audit user objects, long free text -- none of which is rendered.
The configured-fields whitelist still applies, just to the relation root
rather than to each dotted path.
"""
if not relation:
return []
if configured_fields and relation not in configured_fields:
return []
return [f"{relation}.{name}" for name in spec.projection_fields()]
def _list_with_projection_fallback(self, resource: str, *, fields: list[str], **kwargs: Any) -> dict[str, Any]:
"""Run a projected list query, degrading to relation roots if it is rejected.
A misconfigured ``nameFields`` entry would otherwise turn every read into
a hard failure; falling back keeps the tool usable and says why.
"""
try:
return self._list_records_window(resource, fields=fields, **kwargs)
except NbCommandError:
flat = _unique_strings([path.split(".", 1)[0] for path in fields])
if flat == _unique_strings(fields):
raise
result = self._list_records_window(resource, fields=flat, **kwargs)
result.setdefault("data_quality_warnings", []).append(PROJECTION_FALLBACK_WARNING)
return result
-308
View File
@@ -1,308 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
MAX_PAGE_SIZE,
Any,
Mapping,
NbCommandError,
_data,
_format_user_candidate,
_normalize_pagination,
_pick_user_matches,
_ref_from_mapping,
_relation_rows,
_unique_strings,
_unique_values,
build_and_filter,
build_eq_condition,
build_search_filter,
)
class CrmDirectoryMixin(CrmMixin):
def _attach_customer_names(
self,
rows: list[dict[str, Any]],
*,
id_key: str = "customer_id",
max_lookups: int = MAX_PAGE_SIZE,
) -> list[dict[str, Any]]:
"""Fill in customer names for id-keyed aggregation buckets.
Grouping runs on the aggregation endpoint and only ever yields ids, which
left callers reading rows identified by a bare number. Counts are never
touched -- this only adds a name.
"""
identifiers = _unique_values([row.get(id_key) for row in rows if row.get(id_key) is not None])
if not identifiers:
return rows
names: dict[Any, str] = {}
try:
lookup = self.client.list_records(
self.config.customer.resource,
filter={self.config.customer.id_field: {"$in": identifiers[:max_lookups]}},
fields=_unique_strings([self.config.customer.id_field, *(self.config.customer.name_fields or [])]),
page=1,
page_size=max_lookups,
)
except NbCommandError:
return rows
for customer in _data(lookup):
ref = _ref_from_mapping(customer, self._customer_spec())
if ref.get("id") is not None and ref.get("name"):
names[ref["id"]] = str(ref["name"])
for row in rows:
name = names.get(row.get(id_key))
if name:
row["customer_name"] = name
return rows
def resolve_owner(self, query: str) -> dict[str, Any]:
normalized = (query or "").strip()
if not normalized:
return {"query": "", "results": [], "count": 0}
rows = self._owner_matches(normalized)
results = [_format_user_candidate(row, self.config.user.id_field) for row in rows]
return {"query": normalized, "results": results, "count": len(results)}
def list_teams(
self,
query: str | None = None,
*,
include_members: bool = False,
formal_members_only: bool = True,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
result = self._list_records_window(
self.config.team.resource,
limit=limit,
offset=offset,
filter=build_and_filter([build_search_filter(query, [self.config.team.name_field])]),
fields=self._team_fields(),
sort=[self.config.team.name_field],
)
result["results"] = [
self._compact_team(
row,
include_members=include_members,
formal_members_only=formal_members_only,
)
for row in result["results"]
]
return result
def resolve_team(self, query: str) -> dict[str, Any]:
normalized = (query or "").strip()
if not normalized:
return {"query": "", "results": [], "count": 0}
rows = self._team_matches(normalized)
results = [self._compact_team(row, include_members=False, formal_members_only=True) for row in rows]
return {"query": normalized, "results": results, "count": len(results)}
def get_team(
self,
team_id: Any,
*,
include_members: bool = True,
formal_members_only: bool = True,
) -> dict[str, Any]:
row = self._single(
self.client.get_record(
self.config.team.resource,
team_id,
fields=self._team_fields(),
)
)
if not isinstance(row, Mapping):
return {"error": {"code": "team_not_found", "message": f"No team matched id {team_id!r}"}}
return {
"team": self._compact_team(
row,
include_members=include_members,
formal_members_only=formal_members_only,
)
}
def _team_matches(self, team: str) -> list[dict[str, Any]]:
normalized = team.strip()
if not normalized:
return []
rows = self._data(
self.client.list_records(
self.config.team.resource,
filter=build_search_filter(normalized, [self.config.team.name_field]),
fields=self._team_fields(),
page=1,
page_size=50,
)
)
exact = [
row
for row in rows
if str(row.get(self.config.team.name_field) or "").strip().casefold() == normalized.casefold()
]
return exact or rows
def _resolve_team_member_ids_or_error(
self,
team: str | None,
) -> tuple[list[Any] | None, dict[str, Any] | None]:
normalized = (team or "").strip()
if not normalized:
return None, None
matches = self._team_matches(normalized)
candidates = [self._compact_team(row, include_members=False, formal_members_only=True) for row in matches[:10]]
if not matches:
return None, {
"code": "team_not_found",
"message": f"No team matched {normalized!r}",
"query": normalized,
"candidates": [],
}
if len(matches) > 1:
return None, {
"code": "team_ambiguous",
"message": f"Multiple teams matched {normalized!r}",
"query": normalized,
"candidates": candidates,
}
members = self._team_users(matches[0], self.config.team.member_field, formal_only=True)
member_ids = [
row.get(self.config.user.id_field) for row in members if row.get(self.config.user.id_field) is not None
]
if not member_ids:
return None, {
"code": "team_has_no_formal_members",
"message": f"Team {normalized!r} has no formal members",
"query": normalized,
"candidates": candidates,
}
return member_ids, None
def _team_users(
self,
team_row: Mapping[str, Any],
relation_field: str,
*,
formal_only: bool,
) -> list[dict[str, Any]]:
related = _relation_rows(team_row.get(relation_field))
user_ids = [
row.get(self.config.user.id_field) for row in related if row.get(self.config.user.id_field) is not None
]
if not user_ids:
return []
conditions: list[dict[str, Any] | None] = [
{self.config.user.id_field: {"$in": user_ids}},
]
if formal_only:
conditions.append(
build_eq_condition(
self.config.team.user_status_field,
self.config.team.formal_user_status_value,
)
)
fields = _unique_strings(
[
*(self.config.user.fields or []),
self.config.team.user_status_field,
]
)
users, _truncation = self._scan_records(
self.config.user.resource,
filter=build_and_filter(conditions),
fields=fields,
page_size=min(MAX_PAGE_SIZE, max(1, len(user_ids))),
max_records=len(user_ids),
)
return users
def _compact_team(
self,
row: Mapping[str, Any],
*,
include_members: bool,
formal_members_only: bool,
) -> dict[str, Any]:
managers = self._team_users(row, self.config.team.manager_field, formal_only=False)
raw_members = _relation_rows(row.get(self.config.team.member_field))
result: dict[str, Any] = {
"id": row.get(self.config.team.id_field),
"name": row.get(self.config.team.name_field),
"manager_names": [
candidate["label"]
for manager in managers
if (candidate := _format_user_candidate(manager, self.config.user.id_field)).get("label")
],
"member_count": len(raw_members),
}
if include_members:
members = self._team_users(
row,
self.config.team.member_field,
formal_only=formal_members_only,
)
result["members"] = [
{
**_format_user_candidate(member, self.config.user.id_field),
"status": member.get(self.config.team.user_status_field),
}
for member in members
]
result["returned_member_count"] = len(members)
result["formal_members_only"] = formal_members_only
return result
def _owner_matches(self, owner: str) -> list[dict[str, Any]]:
normalized = owner.strip()
if not normalized:
return []
rows = self._data(
self.client.list_records(
self.config.user.resource,
filter=build_search_filter(normalized, self.config.user.match_fields),
fields=self.config.user.fields,
page=1,
page_size=50,
)
)
return _pick_user_matches(rows, normalized, self.config.user.match_fields or [])
def _resolve_owner_id_or_error(self, owner: str | None) -> tuple[Any | None, dict[str, Any] | None]:
normalized = (owner or "").strip()
if not normalized:
return None, None
matches = self._owner_matches(normalized)
candidates = [_format_user_candidate(row, self.config.user.id_field) for row in matches[:10]]
if not matches:
return None, {
"code": "owner_not_found",
"message": f"No user matched owner {normalized!r}",
"query": normalized,
"candidates": [],
}
if len(matches) > 1:
return None, {
"code": "owner_ambiguous",
"message": f"Multiple users matched owner {normalized!r}",
"query": normalized,
"candidates": candidates,
}
user_id = matches[0].get(self.config.user.id_field)
if user_id is None or user_id == "":
return None, {
"code": "owner_invalid",
"message": f"Matched owner {normalized!r} has no {self.config.user.id_field!r}",
"query": normalized,
"candidates": candidates,
}
return user_id, None
def _resolve_owner_id(self, owner: str | None) -> Any | None:
owner_id, error = self._resolve_owner_id_or_error(owner)
if error:
raise ValueError(str(error["message"]))
return owner_id
-184
View File
@@ -1,184 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_schema_helpers import (
_configured_or_search_fields,
_date_field,
_existing_field_names,
_field_list,
_field_names,
_find_field,
_find_named_field,
_metadata_error,
_relation_field,
_template_sort,
)
from .crm_shared import Any
class CrmMetadataMixin(CrmMixin):
def crm_health(self) -> dict[str, Any]:
health = self.client.health(self.config.customer.resource, self.config.customer.id_field)
configured = {
"env": self.config.env_name,
"data_source": self.config.data_source,
"collections": {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.resource,
"teams": self.config.team.resource,
"projects": self.config.project.resource,
"project_updates": self.config.project.update_resource,
},
}
return {"ok": bool(health.get("ok")), "nb": health, "configured": configured}
def crm_schema(self, include_fields: bool = False) -> dict[str, Any]:
schema: dict[str, Any] = {
"env": self.config.env_name,
"data_source": self.config.data_source,
"collections": {
"customers": self.config.customer.__dict__,
"contacts": self.config.contact.__dict__,
"followups": self.config.followup.__dict__,
"users": self.config.user.__dict__,
"teams": self.config.team.__dict__,
"projects": self.config.project.__dict__,
},
}
if include_fields:
schema["metadata"] = self._safe_metadata()
return schema
def crm_config_template(self) -> dict[str, Any]:
metadata = self._safe_metadata()
fields_by_kind = metadata.get("fields", {}) if isinstance(metadata, dict) else {}
customer_fields = _field_list(fields_by_kind.get("customers"))
contact_fields = _field_list(fields_by_kind.get("contacts"))
followup_fields = _field_list(fields_by_kind.get("followups"))
customer_resource = self.config.customer.resource
contact_resource = self.config.contact.resource
return {
"env": self.config.env_name,
"dataSource": self.config.data_source,
"users": {
"resource": self.config.user.resource,
"fields": self.config.user.fields,
"matchFields": self.config.user.match_fields,
"labelFields": self.config.user.label_fields,
},
"collections": {
"customers": {
"resource": customer_resource,
"fields": _field_names(customer_fields),
"searchFields": _configured_or_search_fields(
customer_fields,
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
),
# Ordered: the first non-empty one becomes the customer's
# displayed name everywhere it is referenced.
"nameFields": _existing_field_names(
customer_fields,
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
)
or self.config.customer.name_fields,
"managerField": _find_named_field(customer_fields, ["kehu_manage_person"])
or self.config.customer.manager_field,
"detailFields": {
key: value
for key, value in self.config.customer.detail_fields.items()
if _find_named_field(customer_fields, [value])
},
"statusField": _find_named_field(customer_fields, ["kh_progress"])
or _find_field(customer_fields, ["进度", "状态", "status"]),
"sourceField": _find_named_field(customer_fields, ["f_225h6n6v654"])
or _find_field(customer_fields, ["客户来源", "来源", "source"]),
"ownerField": _find_named_field(customer_fields, ["kehu_followup_person"])
or self.config.customer.owner_field,
"sort": self.config.customer.sort,
},
"contacts": {
"resource": contact_resource,
"fields": _field_names(contact_fields),
"searchFields": _configured_or_search_fields(
contact_fields,
["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"],
),
"nameFields": _existing_field_names(contact_fields, ["f_2zpg91gtmwp"])
or self.config.contact.name_fields,
"titleField": _find_named_field(contact_fields, ["f_tn8zdm2f938"])
or self.config.contact.title_field,
"phoneField": _find_named_field(contact_fields, ["khlxr_phone_number"])
or self.config.contact.phone_field,
"emailField": _find_named_field(contact_fields, ["khlxr_email"]) or self.config.contact.email_field,
"customerField": _find_named_field(contact_fields, ["kehulianxiren_client"])
or _relation_field(contact_fields, ["公司", "客户"])
or self.config.contact.customer_field,
"sort": self.config.contact.sort,
},
"followups": {
"resource": self.config.followup.resource,
"fields": _field_names(followup_fields),
"searchFields": _configured_or_search_fields(followup_fields, ["f_19iplxz0nds"]),
"customerField": _find_named_field(followup_fields, ["gjjl_client"])
or _relation_field(followup_fields, ["客户"])
or self.config.followup.customer_field,
"contactField": _find_named_field(followup_fields, ["gjjl_client_contacts"])
or _relation_field(followup_fields, ["客户联系人", "联系人"])
or self.config.followup.contact_field,
"ownerField": _find_named_field(followup_fields, ["gjjl_followup_person"])
or self.config.followup.owner_field,
"ownerFields": _existing_field_names(
followup_fields,
["gjjl_followup_person", "createdBy"],
)
or self.config.followup.owner_fields,
"dateField": _find_named_field(followup_fields, ["f_srysmjkwfq1"])
or _date_field(followup_fields)
or self.config.followup.date_field,
"contentField": _find_named_field(followup_fields, ["f_19iplxz0nds"])
or self.config.followup.content_field,
"typeField": _find_named_field(followup_fields, ["gjjl_followup_type"])
or self.config.followup.type_field,
"statusField": _find_named_field(followup_fields, ["f_hskl7gjvos9"])
or self.config.followup.status_field,
"projectField": _find_named_field(followup_fields, ["f_gjjl_projects"])
or self.config.followup.project_field,
"locationField": _find_named_field(followup_fields, ["f_b5c4hfi5og6"])
or self.config.followup.location_field,
"sort": _template_sort(
_find_named_field(followup_fields, ["f_srysmjkwfq1"])
or _date_field(followup_fields)
or self.config.followup.date_field,
self.config.followup.sort,
),
},
},
"metadata": {
"field_errors": metadata.get("field_errors", {}) if isinstance(metadata, dict) else {},
"note": "Review inferred owner/status/source/date fields before using this template in production.",
},
}
def _safe_metadata(self) -> dict[str, Any]:
collections = {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.resource,
"teams": self.config.team.resource,
"projects": self.config.project.resource,
"project_updates": self.config.project.update_resource,
}
fields: dict[str, list[dict[str, Any]]] = {}
field_errors: dict[str, dict[str, str]] = {}
for label, collection_name in collections.items():
try:
response = self.client.list_collection_fields(
collection_name,
page_size=500,
paginate=False,
)
fields[label] = self._data(response)
except Exception as error:
field_errors[label] = _metadata_error(error)
return {"collections": collections, "fields": fields, "field_errors": field_errors}
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
from typing import Any
from .config import CrmConfig
from .nb_client import NbClient
class CrmMixin:
"""Typing context shared by composable CRM service mixins."""
config: CrmConfig
client: NbClient
def __getattr__(self, name: str) -> Any:
raise AttributeError(name)
-282
View File
@@ -1,282 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
CUSTOMER_STATUS_LABELS,
FOLLOWUP_CONTENT_KEYS,
FOLLOWUP_DATE_KEYS,
FOLLOWUP_STATUS_LABELS,
FOLLOWUP_TYPE_LABELS,
UNKNOWN_NAME_PREFIX,
Any,
Mapping,
_beijing_datetime,
_compact_field_list,
_fields_with_projections,
_first_text,
_first_value,
_prune,
_ref_ids,
_relation_refs,
)
class CrmPresentersMixin(CrmMixin):
def _project_fields(self) -> list[str]:
project = self.config.project
scalars = [
project.id_field,
project.name_field,
project.customer_field,
project.inquiry_date_field,
project.progress_field,
project.applications_field,
project.part_numbers_field,
project.period_usage_field,
project.details_field,
project.attachment_field,
"createdAt",
"updatedAt",
]
projections = [
*self._projection(project.customer_relation_field, self._customer_spec(), None),
*self._projection(project.contact_field, self._contact_spec(), None),
]
return _fields_with_projections(scalars, projections)
def _compact_project(self, row: Mapping[str, Any], *, include_raw: bool) -> dict[str, Any]:
project = self.config.project
progress = row.get(project.progress_field)
progress_labels = {value: label for label, value in project.progress_values.items()}
compact: dict[str, Any] = {
"id": row.get(project.id_field),
"name": row.get(project.name_field),
"customer_id": row.get(project.customer_field),
"customers": _relation_refs(row, [project.customer_relation_field], self._customer_spec()),
"inquiry_date": row.get(project.inquiry_date_field),
"stage": progress_labels.get(str(progress), progress) if progress is not None else None,
"applications": row.get(project.applications_field),
"part_numbers": row.get(project.part_numbers_field),
"period_usage": row.get(project.period_usage_field),
"details": row.get(project.details_field),
"contacts": _relation_refs(row, [project.contact_field], self._contact_spec()),
"createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone),
"updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone),
}
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_project_update(self, row: Mapping[str, Any]) -> dict[str, Any]:
creators = _relation_refs(row, ["createdBy"], self._user_spec())
return _prune(
{
"id": row.get("id"),
"updated_at": _beijing_datetime(
row.get(self.config.project.update_date_field) or row.get("createdAt"),
self.config.timezone,
),
"content": row.get(self.config.project.update_content_field),
"creator": creators[0] if creators else None,
}
)
def _compact_customers(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_customer(row, include_raw=include_raw) for row in rows]
def _customer_name_candidates(self) -> list[str]:
return [*(self.config.customer.name_fields or []), "name", "title"]
def _customer_compact_fields(self, *, detail: bool = False) -> list[str]:
customer = self.config.customer
scalars = _compact_field_list(
customer.fields,
[
customer.id_field,
*self._customer_name_candidates(),
customer.english_name_field,
customer.territorial_name_field,
customer.status_field,
customer.source_field,
"createdAt",
"updatedAt",
*(customer.detail_fields.values() if detail else []),
],
)
projections = [
*self._projection(customer.owner_field, self._user_spec(), customer.fields),
*self._projection(customer.manager_field, self._user_spec(), customer.fields),
]
return _fields_with_projections(scalars, projections)
def _compact_customer(
self,
row: Mapping[str, Any],
*,
detail: bool = False,
include_raw: bool = False,
) -> dict[str, Any]:
customer = self.config.customer
identifier = row.get(customer.id_field)
name = _first_text(row, self._customer_name_candidates())
if name is None and identifier is not None:
name = f"{UNKNOWN_NAME_PREFIX}{identifier}"
english_name = _first_text(row, [customer.english_name_field, "english_name"])
territorial_name = _first_text(row, [customer.territorial_name_field, "territorial_name"])
status = _first_value(row, [customer.status_field, "status"])
compact: dict[str, Any] = {
"id": identifier,
"name": name,
# Only worth repeating when they carry information the name does not.
"english_name": english_name if english_name != name else None,
"territorial_name": territorial_name if territorial_name != name else None,
"status": CUSTOMER_STATUS_LABELS.get(status, str(status) if status is not None else None),
"source": _first_value(row, [customer.source_field, "source"]),
"owners": _relation_refs(row, [customer.owner_field], self._user_spec()),
"managers": _relation_refs(row, [customer.manager_field], self._user_spec()),
"createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone),
"updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone),
}
if detail:
for output_key, source_field in customer.detail_fields.items():
if output_key not in compact:
compact[output_key] = row.get(source_field)
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_contacts(
self,
rows: list[dict[str, Any]],
*,
include_details: bool = True,
include_raw: bool = False,
) -> list[dict[str, Any]]:
return [self._compact_contact(row, include_details=include_details, include_raw=include_raw) for row in rows]
def _contact_name_candidates(self) -> list[str]:
return [*(self.config.contact.name_fields or []), "name", "title"]
def _contact_compact_fields(self, *, include_details: bool = True) -> list[str]:
contact = self.config.contact
scalars = _compact_field_list(
contact.fields,
[
contact.id_field,
*self._contact_name_candidates(),
contact.title_field,
"position",
*([contact.phone_field, "phone", contact.email_field, "email"] if include_details else []),
"createdAt",
"updatedAt",
],
)
projections = self._projection(contact.customer_field, self._customer_spec(), contact.fields)
return _fields_with_projections(scalars, projections)
def _compact_contact(
self,
row: Mapping[str, Any],
*,
include_details: bool = True,
include_raw: bool = False,
) -> dict[str, Any]:
contact = self.config.contact
compact: dict[str, Any] = {
"id": row.get(contact.id_field),
"name": _first_text(row, self._contact_name_candidates()),
"title": _first_text(row, [contact.title_field, "title", "position"]),
"customers": _relation_refs(row, [contact.customer_field], self._customer_spec()),
"createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone),
"updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone),
}
if include_details:
compact["phone"] = _first_text(row, [contact.phone_field, "phone"])
compact["email"] = _first_text(row, [contact.email_field, "email"])
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_followups(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_followup(row, include_raw=include_raw) for row in rows]
def _followup_content_fields(self) -> list[str]:
return [
self.config.followup.content_field,
*(self.config.followup.search_fields or []),
*FOLLOWUP_CONTENT_KEYS,
]
def _followup_compact_fields(self) -> list[str]:
followup = self.config.followup
scalars = _compact_field_list(
followup.fields,
[
followup.id_field,
followup.date_field,
*FOLLOWUP_DATE_KEYS,
*self._followup_content_fields(),
followup.type_field,
followup.status_field,
followup.location_field,
],
)
projections = [
*self._projection(followup.customer_field, self._customer_spec(), followup.fields),
*self._projection(followup.contact_field, self._contact_spec(), followup.fields),
*self._projection(followup.project_field, self._project_spec(), followup.fields),
*self._projection(followup.owner_field, self._user_spec(), followup.fields),
*self._projection("createdBy", self._user_spec(), followup.fields),
]
return _fields_with_projections(scalars, projections)
def _compact_followup(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
followup = self.config.followup
summary = self._followup_summary(row)
participants = summary["participants"]
followup_type = row.get(followup.type_field)
record_status = row.get(followup.status_field)
compact: dict[str, Any] = {
"id": row.get(followup.id_field),
"followedAt": _beijing_datetime(summary["followedAt"], self.config.timezone),
"customers": summary["customers"],
"contacts": summary["contacts"],
"participants": participants,
"projects": summary["projects"],
"type": FOLLOWUP_TYPE_LABELS.get(str(followup_type), followup_type) if followup_type is not None else None,
"status": (
FOLLOWUP_STATUS_LABELS.get(str(record_status), record_status) if record_status is not None else None
),
"location": _first_text(row, [followup.location_field]),
"content": summary["content"],
}
# A record is usually submitted by one of its own participants; naming the
# submitter again in that case is pure repetition, so it only shows up when
# it actually tells the reader something new.
creator = summary["creator"]
if creator and creator.get("id") not in _ref_ids(participants):
compact["creator"] = creator
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]:
followup = self.config.followup
date_fields = [followup.date_field, *FOLLOWUP_DATE_KEYS]
# owner_fields drives *filtering* and may include createdBy; participants
# are only ever the follow-up person relation (the contribution basis).
participant_fields = [followup.owner_field]
creators = _relation_refs(row, ["createdBy"], self._user_spec())
return {
"content": _first_text(row, self._followup_content_fields()),
"followedAt": _first_text(row, date_fields),
"customers": _relation_refs(row, [followup.customer_field], self._customer_spec()),
"contacts": _relation_refs(row, [followup.contact_field], self._contact_spec()),
"participants": _relation_refs(row, participant_fields, self._user_spec()),
"creator": creators[0] if creators else None,
"projects": _relation_refs(row, [followup.project_field], self._project_spec()),
}
-366
View File
@@ -1,366 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
MAX_PAGE_SIZE,
MAX_SCAN_RECORDS,
Any,
Mapping,
_data,
_normalize_pagination,
_page_from_offset,
_paginated_rows,
_pagination_metadata,
_relation_refs,
_total,
build_and_filter,
build_date_range_condition,
build_eq_condition,
build_relation_id_condition,
build_relation_id_in_condition,
build_search_filter,
)
class CrmRecordsMixin(CrmMixin):
def list_customers(
self,
query: str | None = None,
status: str | None = None,
owner: str | None = None,
team: str | None = None,
source: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
filter_value = build_and_filter(
[
build_eq_condition(self.config.customer.status_field, status),
build_relation_id_condition(self.config.customer.owner_field, owner_id),
build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids),
build_eq_condition(self.config.customer.source_field, source),
build_search_filter(query, self.config.customer.search_fields),
]
)
result = self._list_with_projection_fallback(
self.config.customer.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(self.config.customer.fields or []) if include_raw else self._customer_compact_fields(),
sort=self.config.customer.sort,
)
result["results"] = self._compact_customers(result["results"], include_raw=include_raw)
result["timezone"] = self.config.timezone
return result
def get_customer(
self,
customer_id: Any,
include_contacts: bool = True,
include_recent_followups: bool = True,
followup_limit: int = 10,
include_raw: bool = False,
) -> dict[str, Any]:
fields = self.config.customer.fields if include_raw else self._customer_compact_fields(detail=True)
row = self._single(
self.client.get_record(
self.config.customer.resource,
customer_id,
fields=fields,
)
)
if not isinstance(row, Mapping):
return {
"error": {
"code": "customer_not_found",
"message": f"No customer matches id {customer_id!r}.",
"hint": "Use list_customers to find the customer id.",
"retryable": False,
}
}
result: dict[str, Any] = {
"customer": self._compact_customer(row, detail=True, include_raw=include_raw),
}
if include_contacts:
# Phone and email are deliberately withheld here: a customer overview
# does not need personal contact details. list_contacts still serves them.
contacts_result = self.list_contacts(
customer_id=customer_id,
limit=50,
offset=0,
include_details=False,
)
result["contacts"] = contacts_result["results"]
result["contacts_pagination"] = _pagination_metadata(contacts_result)
if include_recent_followups:
followups_result = self.list_followups(
customer_id=customer_id,
limit=followup_limit,
offset=0,
include_raw=include_raw,
)
result["recent_followups"] = followups_result["results"]
result["recent_followups_pagination"] = _pagination_metadata(followups_result)
result["timezone"] = self.config.timezone
return result
def list_projects(
self,
*,
customer_id: Any | None = None,
team: str | None = None,
stage: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
stage_value = stage
if stage:
if stage in self.config.project.progress_values:
stage_value = self.config.project.progress_values[stage]
elif stage not in self.config.project.progress_values.values():
return {
"error": {
"code": "invalid_project_stage",
"message": "stage must be one of the configured project progress labels or values",
}
}
filter_value = build_and_filter(
[
build_eq_condition(self.config.project.customer_field, customer_id),
build_relation_id_in_condition(
[self.config.project.customer_relation_field, self.config.customer.owner_field, "id"],
team_member_ids,
),
build_eq_condition(self.config.project.progress_field, stage_value),
]
)
result = self._list_with_projection_fallback(
self.config.project.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=self._project_fields(),
sort=["-updatedAt"],
)
result["results"] = [self._compact_project(row, include_raw=include_raw) for row in result["results"]]
result["timezone"] = self.config.timezone
return result
def get_project(
self,
project_id: Any,
*,
include_contacts: bool = True,
include_followups: bool = True,
include_updates: bool = True,
) -> dict[str, Any]:
row = self._single(
self.client.get_record(
self.config.project.resource,
project_id,
fields=self._project_fields(),
)
)
if not isinstance(row, Mapping):
return {"error": {"code": "project_not_found", "message": f"No project matched id {project_id!r}"}}
project = self._compact_project(row, include_raw=False)
result: dict[str, Any] = {"project": project, "timezone": self.config.timezone}
if include_contacts:
# Was returning raw contact rows, phone and email included.
result["contacts"] = _relation_refs(row, [self.config.project.contact_field], self._contact_spec())
if include_followups:
followups_result = self.list_followups(project_id=project_id, limit=100, offset=0)
result["followups"] = followups_result["results"]
result["followups_pagination"] = _pagination_metadata(followups_result)
if include_updates:
updates, truncation = self._scan_records(
self.config.project.update_resource,
filter=build_and_filter(
[build_relation_id_condition(self.config.project.update_project_field, project_id)]
),
fields=[
"id",
self.config.project.update_project_field,
self.config.project.update_date_field,
self.config.project.update_content_field,
self.config.project.update_attachment_field,
"createdBy",
"createdAt",
],
sort=[self.config.project.update_date_field, "createdAt"],
)
result["updates"] = [self._compact_project_update(update) for update in updates]
if truncation:
result["data_quality_warnings"] = ["project updates were truncated at the scan limit"]
result["truncation"] = {"updates": truncation}
return result
def list_contacts(
self,
customer_id: Any | None = None,
query: str | None = None,
limit: int = 20,
offset: int = 0,
include_details: bool = True,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.contact.customer_field, customer_id),
build_search_filter(query, self.config.contact.search_fields),
]
)
result = self._list_with_projection_fallback(
self.config.contact.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(
(self.config.contact.fields or [])
if include_raw
else self._contact_compact_fields(include_details=include_details)
),
sort=self.config.contact.sort,
)
result["results"] = self._compact_contacts(
result["results"], include_details=include_details, include_raw=include_raw
)
result["timezone"] = self.config.timezone
return result
def list_followups(
self,
customer_id: Any | None = None,
contact_id: Any | None = None,
owner: str | None = None,
team: str | None = None,
project_id: Any | None = None,
followup_type: str | None = None,
record_status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.followup.customer_field, customer_id),
build_relation_id_condition(self.config.followup.contact_field, contact_id),
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids),
build_relation_id_condition(self.config.followup.project_field, project_id),
build_eq_condition(self.config.followup.type_field, followup_type),
build_eq_condition(self.config.followup.status_field, record_status),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
result = self._list_with_projection_fallback(
self.config.followup.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(self.config.followup.fields or []) if include_raw else self._followup_compact_fields(),
sort=self.config.followup.sort,
)
result["results"] = self._compact_followups(result["results"], include_raw=include_raw)
result["timezone"] = self.config.timezone
return result
def _list_records_window(
self,
resource: str,
*,
limit: int,
offset: int,
**kwargs: Any,
) -> dict[str, Any]:
page = _page_from_offset(limit, offset)
intra_page_offset = offset % limit
response = self.client.list_records(
resource,
**kwargs,
page=page,
page_size=limit,
)
rows = self._data(response)
selected = rows[intra_page_offset:]
if intra_page_offset and len(rows) == limit and len(selected) < limit:
next_response = self.client.list_records(
resource,
**kwargs,
page=page + 1,
page_size=limit,
)
selected.extend(self._data(next_response)[: limit - len(selected)])
selected = selected[:limit]
total = _total(response, offset + len(selected))
return _paginated_rows(selected, total, limit, offset)
def _scan_records(
self,
resource: str,
*,
page_size: int = MAX_PAGE_SIZE,
max_records: int = MAX_SCAN_RECORDS,
**kwargs: Any,
) -> tuple[list[dict[str, Any]], dict[str, int] | None]:
rows: list[dict[str, Any]] = []
page = 1
total: int | None = None
while len(rows) < max_records:
response = self.client.list_records(
resource,
**kwargs,
page=page,
page_size=page_size,
)
page_rows = self._data(response)
if total is None:
total = _total(response, len(page_rows))
rows.extend(page_rows)
if not page_rows or len(page_rows) < page_size or len(rows) >= total:
break
page += 1
selected = rows[:max_records]
if total is None or len(selected) >= total:
return selected, None
return selected, {
"total": total,
"returned": len(selected),
"limit": max_records,
"next_offset": len(selected),
}
@staticmethod
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
return _data(response)
@staticmethod
def _single(response: Mapping[str, Any]) -> Any:
data = response.get("data")
if isinstance(data, list):
return data[0] if data else None
return data
-376
View File
@@ -1,376 +0,0 @@
from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
FOLLOWUP_DATE_KEYS,
FOLLOWUP_TYPE_LABELS,
INVALID_FOLLOWUP_STATUS,
Any,
ZoneInfo,
_decorate_dimension_counts,
_decorate_id_counts,
_first_text,
_format_user_candidate,
_int_or_default,
_normalize_pagination,
_paginated_rows,
_parse_datetime,
_ref_names,
_relation_ids,
build_and_filter,
build_date_range_condition,
build_relation_id_condition,
build_relation_id_in_condition,
date,
datetime,
)
class CrmReportingMixin(CrmMixin):
def team_crm_overview(
self,
team: str,
*,
date_from: str | None = None,
date_to: str | None = None,
include_member_breakdown: bool = True,
) -> dict[str, Any]:
matches = self._team_matches((team or "").strip())
member_ids, error = self._resolve_team_member_ids_or_error(team)
if error:
return {"error": error}
assert member_ids is not None
team_row = matches[0]
members = self._team_users(team_row, self.config.team.member_field, formal_only=True)
customer_filter = build_and_filter(
[build_relation_id_in_condition(self.config.customer.owner_field, member_ids)]
)
followup_filter = build_and_filter(
[
build_relation_id_in_condition(self.config.followup.owner_field, member_ids),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
{self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}},
]
)
project_filter = build_and_filter(
[
build_relation_id_in_condition(
[self.config.project.customer_relation_field, self.config.customer.owner_field, "id"],
member_ids,
),
{
self.config.project.progress_field: {
"$ne": self.config.project.progress_values["终止"],
}
},
]
)
result: dict[str, Any] = {
"team": self._compact_team(team_row, include_members=True, formal_members_only=True),
"period": {"date_from": date_from, "date_to": date_to},
"timezone": self.config.timezone,
"totals": {
"owned_customers": self._count(
self.config.customer.resource,
self.config.customer.id_field,
customer_filter,
),
"followup_records": self._count(
self.config.followup.resource,
self.config.followup.id_field,
followup_filter,
),
"active_projects": self._count(
self.config.project.resource,
self.config.project.id_field,
project_filter,
),
},
"by_followup_type": self._group_count(
self.config.followup.resource,
self.config.followup.id_field,
self.config.followup.type_field,
followup_filter,
),
"by_customer": self._group_count(
self.config.followup.resource,
self.config.followup.id_field,
[self.config.followup.customer_field, "id"],
followup_filter,
),
"by_project_stage": self._group_count(
self.config.project.resource,
self.config.project.id_field,
self.config.project.progress_field,
project_filter,
),
"contribution_basis": {
"participant_field": self.config.followup.owner_field,
"team_totals": "unique follow-up record IDs",
"member_contribution": "one participation per member per follow-up",
},
}
if include_member_breakdown:
breakdown: list[dict[str, Any]] = []
for member in members:
member_id = member.get(self.config.user.id_field)
if member_id is None:
continue
member_customer_filter = build_and_filter(
[build_relation_id_condition(self.config.customer.owner_field, member_id)]
)
member_followup_filter = build_and_filter(
[
build_relation_id_condition(self.config.followup.owner_field, member_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
{self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}},
]
)
candidate = _format_user_candidate(member, self.config.user.id_field)
breakdown.append(
{
"member": candidate,
"owned_customers": self._count(
self.config.customer.resource,
self.config.customer.id_field,
member_customer_filter,
),
"followup_participations": self._count(
self.config.followup.resource,
self.config.followup.id_field,
member_followup_filter,
),
}
)
result["by_member"] = sorted(
breakdown,
key=lambda item: (-item["followup_participations"], str(item["member"].get("label") or "")),
)
result["by_followup_type"] = _decorate_dimension_counts(
result["by_followup_type"],
preferred_keys=[self.config.followup.type_field],
labels=FOLLOWUP_TYPE_LABELS,
)
result["by_project_stage"] = _decorate_dimension_counts(
result["by_project_stage"],
preferred_keys=[self.config.project.progress_field],
labels={value: label for label, value in self.config.project.progress_values.items()},
)
result["by_customer"] = self._attach_customer_names(
_decorate_id_counts(
result["by_customer"],
preferred_keys=[
f"{self.config.followup.customer_field}_id",
"gjjl_client_id",
],
output_key="customer_id",
)
)
return result
def get_project_timeline(
self,
project_id: Any,
*,
date_from: str | None = None,
date_to: str | None = None,
) -> dict[str, Any]:
project_result = self.get_project(
project_id,
include_contacts=False,
include_followups=False,
include_updates=False,
)
if "error" in project_result:
return project_result
project = project_result["project"]
followup_rows, followups_truncation = self._scan_records(
self.config.followup.resource,
filter=build_and_filter(
[
build_relation_id_condition(self.config.followup.project_field, project_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
),
fields=self._followup_compact_fields(),
sort=self.config.followup.sort,
)
update_rows, updates_truncation = self._scan_records(
self.config.project.update_resource,
filter=build_and_filter(
[
build_relation_id_condition(self.config.project.update_project_field, project_id),
build_date_range_condition(self.config.project.update_date_field, date_from, date_to),
]
),
fields=[
"id",
self.config.project.update_date_field,
self.config.project.update_content_field,
"createdBy",
"createdAt",
],
sort=[self.config.project.update_date_field, "createdAt"],
)
events: list[dict[str, Any]] = []
for row in followup_rows:
compact = self._compact_followup(row)
events.append(
{
"event_type": "followup",
"occurred_at": compact.get("followedAt"),
"actors": _ref_names(compact.get("participants", [])),
"summary": compact.get("content"),
"source_resource": self.config.followup.resource,
"source_id": compact.get("id"),
}
)
for row in update_rows:
compact = self._compact_project_update(row)
events.append(
{
"event_type": "project_update",
"occurred_at": compact.get("updated_at"),
"actors": _ref_names([compact["creator"]] if compact.get("creator") else []),
"summary": compact.get("content"),
"source_resource": self.config.project.update_resource,
"source_id": compact.get("id"),
}
)
events.sort(key=lambda event: str(event.get("occurred_at") or ""))
result = {
"project": project,
"events": events,
"timezone": self.config.timezone,
}
warnings: list[str] = []
truncation: dict[str, Any] = {}
if followups_truncation:
warnings.append("follow-up events were truncated at the scan limit")
truncation["followups"] = followups_truncation
if updates_truncation:
warnings.append("project update events were truncated at the scan limit")
truncation["updates"] = updates_truncation
if warnings:
result["data_quality_warnings"] = warnings
result["truncation"] = truncation
return result
def list_stale_customers(
self,
*,
team: str | None = None,
owner: str | None = None,
inactive_days: int = 30,
as_of: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
inactive_days = max(1, _int_or_default(inactive_days, 30))
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
try:
as_of_date = date.fromisoformat(as_of) if as_of else datetime.now(ZoneInfo(self.config.timezone)).date()
except ValueError:
return {"error": {"code": "invalid_date", "message": "as_of must use YYYY-MM-DD"}}
customer_filter = build_and_filter(
[
build_relation_id_condition(self.config.customer.owner_field, owner_id),
build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids),
]
)
followup_filter = build_and_filter(
[
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids),
{self.config.followup.date_field: {"$dateNotAfter": as_of_date.isoformat()}},
{self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}},
]
)
customer_rows, customers_truncation = self._scan_records(
self.config.customer.resource,
filter=customer_filter,
fields=self._customer_compact_fields(),
sort=self.config.customer.sort,
)
followup_rows, followups_truncation = self._scan_records(
self.config.followup.resource,
filter=followup_filter,
# This scan can pull thousands of rows and only needs the customer id,
# so the customer relation is projected down to exactly that.
fields=[
self.config.followup.id_field,
f"{self.config.followup.customer_field}.{self.config.customer.id_field}",
self.config.followup.date_field,
self.config.followup.status_field,
],
sort=self.config.followup.sort,
)
last_followup: dict[Any, datetime] = {}
for followup in followup_rows:
followed_at = _parse_datetime(
_first_text(
followup,
[self.config.followup.date_field, *FOLLOWUP_DATE_KEYS],
),
self.config.timezone,
)
if followed_at is None:
continue
for customer_id in _relation_ids(
followup.get(self.config.followup.customer_field),
self.config.customer.id_field,
):
previous = last_followup.get(customer_id)
if previous is None or followed_at > previous:
last_followup[customer_id] = followed_at
stale: list[dict[str, Any]] = []
for customer in customer_rows:
customer_id = customer.get(self.config.customer.id_field)
last = last_followup.get(customer_id)
days_since = (as_of_date - last.astimezone(ZoneInfo(self.config.timezone)).date()).days if last else None
if days_since is not None and days_since < inactive_days:
continue
stale.append(
{
"customer": self._compact_customer(customer),
"last_followup_at": (last.astimezone(ZoneInfo(self.config.timezone)).isoformat() if last else None),
"days_since_last_followup": days_since,
"never_followed_up": last is None,
}
)
stale.sort(
key=lambda item: (
item["days_since_last_followup"] is not None,
-(item["days_since_last_followup"] or 0),
str(item["customer"].get("name") or ""),
)
)
selected = stale[offset : offset + limit]
result = _paginated_rows(selected, len(stale), limit, offset)
result.update(
{
"as_of": as_of_date.isoformat(),
"inactive_days": inactive_days,
"timezone": self.config.timezone,
}
)
warnings: list[str] = []
truncation: dict[str, Any] = {}
if customers_truncation:
warnings.append("customer scan was truncated")
truncation["customers"] = customers_truncation
if followups_truncation:
warnings.append("follow-up scan was truncated")
truncation["followups"] = followups_truncation
if warnings:
result["data_quality_warnings"] = warnings
result["truncation"] = truncation
return result
-145
View File
@@ -1,145 +0,0 @@
from __future__ import annotations
from typing import Any
def _metadata_error(error: Exception) -> dict[str, str]:
message = str(error)
if "403" in message or "No permissions" in message:
return {
"code": "metadata_permission_required",
"message": message,
"hint": (
"Grant the agent role read-only permission for "
"`data-modeling collections fields list`, then retry crm_schema(include_fields=true)."
),
}
return {"code": "metadata_error", "message": message, "hint": "Check NocoBase metadata API reachability and ACL."}
def _field_list(value: Any) -> list[dict[str, Any]]:
return [item for item in value or [] if isinstance(item, dict)]
def _field_names(fields: list[dict[str, Any]]) -> list[str]:
return [str(field["name"]) for field in fields if field.get("name")]
def _field_path(field: str | list[str]) -> list[str]:
if isinstance(field, str):
return [part for part in field.split(".") if part]
return [str(part) for part in field if str(part)]
def _field_text(field: dict[str, Any]) -> str:
pieces = [
str(field.get("name") or ""),
str(field.get("title") or ""),
str(field.get("interface") or ""),
str(field.get("type") or ""),
]
ui_schema = field.get("uiSchema")
if isinstance(ui_schema, dict):
pieces.append(str(ui_schema.get("title") or ""))
return " ".join(pieces).lower()
def _find_named_field(fields: list[dict[str, Any]], names: list[str]) -> str | None:
available = {str(field.get("name")) for field in fields if field.get("name")}
for name in names:
if name in available:
return name
return None
def _existing_field_names(fields: list[dict[str, Any]], names: list[str]) -> list[str]:
available = {str(field.get("name")) for field in fields if field.get("name")}
return [name for name in names if name in available]
def _configured_or_search_fields(fields: list[dict[str, Any]], preferred_names: list[str]) -> list[str]:
preferred = _existing_field_names(fields, preferred_names)
if preferred:
return preferred
return _search_fields(fields)
def _search_fields(fields: list[dict[str, Any]]) -> list[str]:
preferred_interfaces = {"input", "textarea", "email", "phone", "url"}
preferred_types = {"string", "text"}
names = [
str(field["name"])
for field in fields
if field.get("name")
and (
str(field.get("interface") or "").lower() in preferred_interfaces
or str(field.get("type") or "").lower() in preferred_types
)
]
if names:
return names[:4]
fallback = [str(field["name"]) for field in fields if field.get("name") and str(field.get("name")) != "id"]
return fallback[:2]
def _find_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None:
lowered = [needle.lower() for needle in needles]
for field in fields:
name = field.get("name")
if not name:
continue
text = _field_text(field)
if any(needle in text for needle in lowered):
return str(name)
return None
def _relation_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None:
lowered = [needle.lower() for needle in needles]
for field in fields:
if not field.get("name"):
continue
if str(field.get("type") or "").lower() not in {"belongsto", "belongstomany", "hasmany", "hasone"}:
continue
text = _field_text(field)
if any(needle in text for needle in lowered):
return str(field["name"])
return None
def _date_field(fields: list[dict[str, Any]]) -> str | None:
preferred = _find_field(fields, ["跟进日期", "跟进时间", "follow", "date", "time"])
if preferred:
return preferred
for field in fields:
if field.get("name") and str(field.get("interface") or "").lower() in {"date", "datetime", "createdat"}:
return str(field["name"])
if field.get("name") and str(field.get("type") or "").lower() in {"date", "datetime"}:
return str(field["name"])
return None
def _template_sort(date_field: str | None, fallback: list[str] | None) -> list[str] | None:
if date_field:
sort = [f"-{date_field}"]
if date_field != "createdAt":
sort.append("-createdAt")
return sort
return fallback
__all__ = [
"_configured_or_search_fields",
"_date_field",
"_existing_field_names",
"_field_list",
"_field_names",
"_field_path",
"_field_text",
"_find_field",
"_find_named_field",
"_metadata_error",
"_relation_field",
"_search_fields",
"_template_sort",
]
-472
View File
@@ -1,472 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from .config import CrmConfig
from .filters import (
build_and_filter,
build_date_range_condition,
build_eq_condition,
build_relation_id_condition,
build_relation_id_in_condition,
build_search_filter,
)
from .nb_client import NbClient, NbCommandError
__all__ = [
"Any",
"CUSTOMER_STATUS_LABELS",
"CrmConfig",
"FOLLOWUP_CONTENT_KEYS",
"FOLLOWUP_DATE_KEYS",
"FOLLOWUP_STATUS_LABELS",
"FOLLOWUP_TYPE_LABELS",
"INVALID_FOLLOWUP_STATUS",
"LabelSpec",
"MAX_PAGE_SIZE",
"MAX_SCAN_RECORDS",
"Mapping",
"NbClient",
"NbCommandError",
"PROJECTION_FALLBACK_WARNING",
"UNKNOWN_NAME_PREFIX",
"ZoneInfo",
"_beijing_datetime",
"_compact_field_list",
"_data",
"_decorate_dimension_counts",
"_decorate_id_counts",
"_dimension_value",
"_fields_with_projections",
"_first_numeric",
"_first_text",
"_first_value",
"_format_user_candidate",
"_int_or_default",
"_normalize_pagination",
"_page_from_offset",
"_paginated_rows",
"_pagination_metadata",
"_parse_datetime",
"_pick_user_matches",
"_prune",
"_ref_from_mapping",
"_ref_ids",
"_ref_names",
"_relation_ids",
"_relation_refs",
"_relation_rows",
"_total",
"_unique_strings",
"_unique_values",
"build_and_filter",
"build_date_range_condition",
"build_eq_condition",
"build_relation_id_condition",
"build_relation_id_in_condition",
"build_search_filter",
"date",
"datetime",
]
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
MAX_SCAN_RECORDS = 5000
UNKNOWN_NAME_PREFIX = "#"
PROJECTION_FALLBACK_WARNING = "NocoBase rejected relation field projection; check nameFields in the CRM config"
CUSTOMER_STATUS_LABELS = {
"kh_p_not_start": "未开始",
"kh_p_inprogress": "进行中",
"kh_p_ordered": "已下单",
"kh_p_audited": "已审核",
}
FOLLOWUP_CONTENT_KEYS = ("f_19iplxz0nds", "content", "description", "remark")
FOLLOWUP_DATE_KEYS = ("f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt")
FOLLOWUP_TYPE_LABELS = {
"tdwfk5h1ohv": "邮件",
"s48pnyhutwc": "电话",
"mwyy8xwk66m": "视频会议",
"m34aocz3ebo": "拜访/请客",
"0lmj78zpdnm": "审厂/来访",
"o7rjlg4guzh": "论坛/大会",
"2csix8r3h0f": "代理跟进情况代填",
}
FOLLOWUP_STATUS_LABELS = {
"2rowk1bc8vc": "审批中",
"3ge6geal2u1": "OK",
"68vqz8raus6": "迟交",
"b5pf5njqgp6": "无效",
}
INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6"
@dataclass(frozen=True)
class LabelSpec:
"""How to identify and name one related record.
Every relation the API renders (customer, contact, user, project) resolves to
the same ``{"id": ..., "name": ...}`` shape through this spec, so the same
entity looks identical in every tool response.
"""
id_field: str = "id"
name_fields: tuple[str, ...] = ()
def projection_fields(self) -> list[str]:
return _unique_strings([self.id_field, *self.name_fields])
def _page_from_offset(limit: int, offset: int) -> int:
bounded_limit = max(1, int(limit))
bounded_offset = max(0, int(offset))
return bounded_offset // bounded_limit + 1
def _normalize_pagination(limit: Any, offset: Any) -> tuple[int, int]:
return min(max(1, _int_or_default(limit, DEFAULT_PAGE_SIZE)), MAX_PAGE_SIZE), max(0, _int_or_default(offset, 0))
def _compact_field_list(configured_fields: list[str] | None, candidates: list[str]) -> list[str]:
available = set(configured_fields or [])
selected: list[str] = []
for candidate in _unique_strings(candidates):
if available and candidate not in available:
continue
selected.append(candidate)
return selected
def _int_or_default(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
data = response.get("data")
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in ("data", "rows", "results"):
nested = data.get(key)
if isinstance(nested, list):
return [item for item in nested if isinstance(item, dict)]
return []
def _total(response: Mapping[str, Any], fallback: int) -> int:
meta = response.get("meta")
if isinstance(meta, dict):
for key in ("count", "total", "totalCount"):
value = meta.get(key)
if isinstance(value, int):
return value
data = response.get("data")
if isinstance(data, dict):
for key in ("count", "total", "totalCount"):
value = data.get(key)
if isinstance(value, int):
return value
return fallback
def _paginated_response(response: Mapping[str, Any], limit: int, offset: int) -> dict[str, Any]:
rows = _data(response)
total = _total(response, len(rows))
return _paginated_rows(rows, total, limit, offset)
def _paginated_rows(rows: list[dict[str, Any]], total: int, limit: int, offset: int) -> dict[str, Any]:
next_offset = offset + len(rows)
return {
"results": rows,
"total": total,
"returned": len(rows),
"limit": limit,
"offset": offset,
"has_more": bool(rows) and next_offset < total,
"next_offset": next_offset if next_offset < total else None,
}
def _pagination_metadata(result: Mapping[str, Any]) -> dict[str, Any]:
return {key: result.get(key) for key in ("total", "returned", "limit", "offset", "has_more", "next_offset")}
def _beijing_datetime(value: Any, timezone: str) -> Any:
if not isinstance(value, str) or not value.strip():
return value
normalized = value.strip()
if len(normalized) == 10:
try:
datetime.strptime(normalized, "%Y-%m-%d")
except ValueError:
return value
return normalized
candidate = normalized[:-1] + "+00:00" if normalized.endswith("Z") else normalized
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
try:
return parsed.astimezone(ZoneInfo(timezone)).isoformat()
except (ValueError, KeyError):
return parsed.astimezone(ZoneInfo("Asia/Shanghai")).isoformat()
def _parse_datetime(value: Any, timezone: str) -> datetime | None:
normalized = _beijing_datetime(value, timezone)
if not isinstance(normalized, str) or len(normalized) == 10:
return None
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
def _first_numeric(rows: list[dict[str, Any]], preferred_key: str) -> int:
for row in rows:
value = row.get(preferred_key)
if isinstance(value, (int, float)):
return int(value)
for nested_value in row.values():
if isinstance(nested_value, (int, float)):
return int(nested_value)
return 0
def _decorate_dimension_counts(
rows: list[dict[str, Any]],
*,
preferred_keys: list[str],
labels: Mapping[Any, str],
) -> list[dict[str, Any]]:
decorated: list[dict[str, Any]] = []
for row in rows:
value, key = _dimension_value(row, preferred_keys)
item = dict(row)
if value is not None:
label = labels.get(value) or str(value)
# The raw dimension key is dropped once normalized -- keeping it meant
# every bucket carried the same value under an internal field code too.
if key is not None and key not in {"value", "label"}:
item.pop(key, None)
item["value"] = value
item["label"] = label
decorated.append(item)
decorated.sort(key=lambda item: (_is_blank(item.get("value")), -_count_value(item), str(item.get("label") or "")))
return decorated
def _decorate_id_counts(
rows: list[dict[str, Any]],
*,
preferred_keys: list[str],
output_key: str,
) -> list[dict[str, Any]]:
decorated: list[dict[str, Any]] = []
for row in rows:
value, key = _dimension_value(row, preferred_keys)
item = dict(row)
if value is not None:
if key is not None and key != output_key:
item.pop(key, None)
item[output_key] = value
decorated.append(item)
decorated.sort(key=lambda item: (-_count_value(item), str(item.get(output_key) or "")))
return decorated
def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple[Any | None, str | None]:
for key in preferred_keys:
if key in row:
return row[key], key
for key, value in row.items():
if key == "count":
continue
if not isinstance(value, (dict, list)):
return value, str(key)
return None, None
def _count_value(row: Mapping[str, Any]) -> int:
value = row.get("count")
if isinstance(value, (int, float)):
return int(value)
return 0
def _is_blank(value: Any) -> bool:
return value is None or str(value).strip() == ""
def _first_value(row: Mapping[str, Any], fields: list[str]) -> Any:
for field in _unique_strings(fields):
value = row.get(field)
if value is not None and value != "":
return value
return None
def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
for field in _unique_strings(fields):
value = row.get(field)
if value is None or value == "":
continue
if isinstance(value, (dict, list)):
continue
return str(value)
return None
def _prune(mapping: Mapping[str, Any]) -> dict[str, Any]:
"""Drop keys whose value carries no information.
An absent key means "empty", never "unknown" -- callers reconciling records
should not have to skip past a screenful of nulls and empty lists. ``id`` is
always kept so a record stays addressable even when nothing else resolved.
"""
pruned: dict[str, Any] = {}
for key, value in mapping.items():
if key == "id" or not _is_empty(value):
pruned[key] = value
return pruned
def _is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return value.strip() == ""
if isinstance(value, (list, tuple, dict, set)):
return len(value) == 0
return False
def _ref_from_mapping(item: Mapping[str, Any], spec: LabelSpec) -> dict[str, Any]:
"""Render one related record as ``{"id": ..., "name": ...}``.
Deliberately never falls back to the raw id as the *name*: a customer whose
name fields are all empty used to surface as e.g. ``"4249"``, which reads
like a real name. It becomes ``"#4249"`` instead -- unmistakably an id, and
a signal that the source record needs its name filled in.
"""
ident = item.get(spec.id_field)
name = _first_text(item, list(spec.name_fields))
if name is None and ident is not None:
name = f"{UNKNOWN_NAME_PREFIX}{ident}"
return _prune({"id": ident, "name": name})
def _relation_refs(row: Mapping[str, Any], fields: list[str], spec: LabelSpec) -> list[dict[str, Any]]:
refs: list[dict[str, Any]] = []
seen: set[Any] = set()
for field in _unique_strings(fields):
for item in _relation_rows(row.get(field)):
ref = _ref_from_mapping(item, spec)
if not ref:
continue
key = ref.get("id", ref.get("name"))
if key in seen:
continue
seen.add(key)
refs.append(ref)
return refs
def _ref_names(refs: list[dict[str, Any]]) -> list[str]:
return [str(ref["name"]) for ref in refs if ref.get("name")]
def _ref_ids(refs: list[dict[str, Any]]) -> list[Any]:
return [ref["id"] for ref in refs if ref.get("id") is not None]
def _fields_with_projections(scalars: list[str], projections: list[str]) -> list[str]:
"""Combine scalar field names with dotted relation projections.
A relation requested bare (``gjjl_client``) next to a dotted projection
(``gjjl_client.id``) makes NocoBase expand the whole related row again, which
is exactly the bloat the projections exist to avoid -- so any scalar that is
also a projection root is dropped.
"""
roots = {path.split(".", 1)[0] for path in projections}
kept = [name for name in _unique_strings(scalars) if name not in roots]
return kept + _unique_strings(projections)
def _relation_rows(value: Any) -> list[dict[str, Any]]:
if isinstance(value, Mapping):
nested = value.get("data")
if isinstance(nested, list):
return [dict(item) for item in nested if isinstance(item, Mapping)]
return [dict(value)]
if isinstance(value, list):
return [dict(item) for item in value if isinstance(item, Mapping)]
return []
def _relation_ids(value: Any, id_field: str = "id") -> list[Any]:
return [row.get(id_field) for row in _relation_rows(value) if row.get(id_field) is not None]
def _unique_values(values: list[Any]) -> list[Any]:
unique: list[Any] = []
seen: set[Any] = set()
for value in values:
if value in seen:
continue
seen.add(value)
unique.append(value)
return unique
def _unique_strings(values: list[str]) -> list[str]:
unique: list[str] = []
for value in values:
normalized = str(value or "").strip()
if normalized and normalized not in unique:
unique.append(normalized)
return unique
def _pick_user_matches(rows: list[dict[str, Any]], query: str, match_fields: list[str]) -> list[dict[str, Any]]:
normalized = query.casefold()
exact = [
row for row in rows if any(str(row.get(field) or "").strip().casefold() == normalized for field in match_fields)
]
if exact:
return exact
fuzzy = [
row for row in rows if any(normalized in str(row.get(field) or "").strip().casefold() for field in match_fields)
]
return fuzzy
def _user_label(row: dict[str, Any]) -> str:
for key in ("nickname", "username", "email", "f_yh_english_name", "id"):
value = row.get(key)
if value is not None and value != "":
return str(value)
return "<unnamed>"
def _format_user_candidate(row: dict[str, Any], id_field: str) -> dict[str, Any]:
candidate: dict[str, Any] = {
"id": row.get(id_field),
"label": _user_label(row),
}
for key in ("username", "nickname", "email", "f_yh_english_name"):
value = row.get(key)
if value is not None and value != "":
candidate[key] = value
return candidate
-98
View File
@@ -1,98 +0,0 @@
from __future__ import annotations
from collections.abc import Iterable, Sequence
from typing import Any
Filter = dict[str, Any]
def build_search_filter(query: str | None, fields: Iterable[str] | None) -> Filter | None:
normalized = (query or "").strip()
field_names = [field for field in (fields or []) if field]
if not normalized or not field_names:
return None
return {"$or": [{field: {"$includes": normalized}} for field in field_names]}
def build_eq_condition(field: str | None, value: Any) -> Filter | None:
if not field or value is None or value == "":
return None
return {field: {"$eq": value}}
def build_relation_id_condition(field: str | Sequence[str] | None, value: Any) -> Filter | None:
if not field or value is None or value == "":
return None
path = _relation_path(field)
if not path:
return None
return _nested_eq(path, value)
def build_relation_id_or_condition(fields: Iterable[str | Sequence[str]] | None, value: Any) -> Filter | None:
if value is None or value == "":
return None
conditions = [condition for field in fields or [] if (condition := build_relation_id_condition(field, value))]
if not conditions:
return None
if len(conditions) == 1:
return {"$or": conditions}
return {"$or": conditions}
def build_relation_id_in_condition(
field: str | Sequence[str] | None,
values: Iterable[Any] | None,
) -> Filter | None:
if not field:
return None
normalized_values = [value for value in values or [] if value is not None and value != ""]
if not normalized_values:
return None
path = _relation_path(field)
if not path:
return None
current: Any = {"$in": normalized_values}
for part in reversed(path):
current = {part: current}
return current
def build_date_range_condition(field: str | None, date_from: str | None, date_to: str | None) -> Filter | None:
if not field:
return None
lower = (date_from or "").strip()
upper = (date_to or "").strip()
if lower and upper:
return {field: {"$dateBetween": [lower, upper]}}
if lower:
return {field: {"$dateNotBefore": lower}}
if upper:
return {field: {"$dateNotAfter": upper}}
return None
def build_and_filter(conditions: Iterable[Filter | None]) -> Filter:
filtered = [condition for condition in conditions if condition]
if not filtered:
return {}
return {"$and": filtered}
def _relation_path(field: str | Sequence[str]) -> list[str]:
if isinstance(field, str):
parts = [part for part in field.split(".") if part]
else:
parts = [str(part) for part in field if str(part)]
if not parts:
return []
if len(parts) == 1:
return [parts[0], "id"]
return parts
def _nested_eq(path: Sequence[str], value: Any) -> Filter:
current: Any = {"$eq": value}
for part in reversed(path):
current = {part: current}
return current
-427
View File
@@ -1,427 +0,0 @@
from __future__ import annotations
import json
import os
import re
import subprocess
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Set as AbstractSet
from pathlib import Path
from typing import Any
Runner = Callable[..., subprocess.CompletedProcess[str]]
class NbCommandError(RuntimeError):
"""Raised when the NocoBase CLI command fails or returns invalid output."""
class NbAuthenticationError(NbCommandError):
"""Raised when NocoBase rejects the configured API credential."""
class NbClient:
def __init__(
self,
*,
env_name: str = "nb-mcp",
data_source: str = "main",
nb_bin: str = "nb",
api_base_url: str | None = None,
api_token: str | None = None,
role: str | None = None,
cwd: str | Path | None = None,
timeout: int = 30,
runner: Runner | None = None,
) -> None:
self.env_name = env_name
self.data_source = data_source
expanded_nb_bin = Path(nb_bin).expanduser()
self.nb_bin = str(expanded_nb_bin) if str(nb_bin).startswith("~") or expanded_nb_bin.is_absolute() else nb_bin
self.api_base_url = api_base_url.strip().rstrip("/") if api_base_url else None
self.api_token = api_token
self.role = role
self.cwd = Path(cwd).resolve() if cwd else None
self.timeout = timeout
self.runner = runner or subprocess.run
def health(self, resource: str, id_field: str) -> dict[str, Any]:
self.list_records(resource, fields=[id_field], page=1, page_size=1)
return {
"ok": True,
"env": self.env_name,
"status_output": "authenticated",
"stderr": "",
}
def environment_credentials(self) -> tuple[str, str]:
"""Return the selected CLI environment's API URL and current user token.
The token is read from ``nb env info`` output and is never added to a
subprocess command line. This is intended for the few multipart or
relation endpoints that the CLI does not expose as first-class commands.
"""
command = [
self.nb_bin,
"env",
"info",
self.env_name,
"--json",
"--show-secrets",
]
result = self._run_raw(command[1:], full_command=command)
if result.returncode != 0:
raise NbCommandError(
f"Could not read nb environment {self.env_name!r} (exit code {result.returncode}). "
f"Run `nb env info {self.env_name}` to verify it."
)
try:
payload = self._parse_json_object(result.stdout)
except (json.JSONDecodeError, NbCommandError) as error:
raise NbCommandError(f"nb env info returned invalid JSON for environment {self.env_name!r}") from error
api = payload.get("api")
auth = api.get("auth") if isinstance(api, Mapping) else None
api_base_url = api.get("apiBaseUrl") if isinstance(api, Mapping) else None
access_token = auth.get("accessToken") if isinstance(auth, Mapping) else None
if not isinstance(api_base_url, str) or not api_base_url.strip():
raise NbCommandError(f"nb environment {self.env_name!r} has no API base URL")
if not isinstance(access_token, str) or not access_token.strip() or access_token in {"-", "********"}:
raise NbAuthenticationError(
f"nb environment {self.env_name!r} is not authenticated. "
"Ask the local installation Agent to reconnect it with your personal NocoBase API Key."
)
return api_base_url.strip().rstrip("/"), access_token.strip()
def list_records(
self,
resource: str,
*,
filter: Mapping[str, Any] | None = None,
fields: Sequence[str] | None = None,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
sort: Sequence[str] | None = None,
page: int | None = None,
page_size: int | None = None,
paginate: bool | None = None,
) -> dict[str, Any]:
command = self._resource_command("list", resource)
self._append_json(command, "--filter", filter)
self._append_repeated(command, "--fields", fields)
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
self._append_repeated(command, "--sort", sort)
self._append_value(command, "--page", page)
self._append_value(command, "--page-size", page_size)
if paginate is True:
command.append("--paginate")
elif paginate is False:
command.append("--no-paginate")
return self._run_json(command)
def get_record(
self,
resource: str,
filter_by_tk: Any,
*,
fields: Sequence[str] | None = None,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
) -> dict[str, Any]:
command = self._resource_command("get", resource)
self._append_json_or_scalar(command, "--filter-by-tk", filter_by_tk)
self._append_repeated(command, "--fields", fields)
self._append_repeated(command, "--appends", appends)
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,
*,
measures: Sequence[Mapping[str, Any]] | None = None,
dimensions: Sequence[Mapping[str, Any]] | None = None,
orders: Sequence[Mapping[str, Any]] | None = None,
filter: Mapping[str, Any] | None = None,
having: Mapping[str, Any] | None = None,
limit: int | None = None,
offset: int | None = None,
timezone: str | None = None,
) -> dict[str, Any]:
command = self._resource_command("query", resource)
self._append_json(command, "--measures", measures)
self._append_json(command, "--dimensions", dimensions)
self._append_json(command, "--orders", orders)
self._append_json(command, "--filter", filter)
self._append_json(command, "--having", having)
self._append_value(command, "--limit", limit)
self._append_value(command, "--offset", offset)
self._append_value(command, "--timezone", timezone)
return self._run_json(command)
def get_collection(
self,
collection_name: str,
*,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
) -> dict[str, Any]:
command = self._data_modeling_command("collections", "get")
command.extend(["--filter-by-tk", collection_name])
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
return self._run_json(command)
def list_collection_fields(
self,
collection_name: str,
*,
fields: Sequence[str] | None = None,
appends: Sequence[str] | None = None,
except_fields: Sequence[str] | None = None,
page: int | None = None,
page_size: int | None = None,
paginate: bool | None = None,
) -> dict[str, Any]:
command = self._data_modeling_command("collections", "fields", "list")
command.extend(["--collection-name", collection_name])
self._append_repeated(command, "--fields", fields)
self._append_repeated(command, "--appends", appends)
self._append_repeated(command, "--except", except_fields)
self._append_value(command, "--page", page)
self._append_value(command, "--page-size", page_size)
if paginate is True:
command.append("--paginate")
elif paginate is False:
command.append("--no-paginate")
return self._run_json(command)
def _resource_command(self, action: str, resource: str) -> list[str]:
command = [
self.nb_bin,
"api",
"resource",
action,
"--resource",
resource,
"--env",
self.env_name,
"--yes",
"--json-output",
]
if self.data_source:
command.extend(["--data-source", self.data_source])
if self.api_base_url:
command.extend(["--api-base-url", self.api_base_url])
if self.role:
command.extend(["--role", self.role])
if self.api_token:
command.extend(["--token", self.api_token])
return command
def _data_modeling_command(self, *parts: str) -> list[str]:
command = [
self.nb_bin,
"api",
"data-modeling",
*parts,
"--env",
self.env_name,
"--yes",
"--json-output",
]
if self.api_base_url:
command.extend(["--api-base-url", self.api_base_url])
if self.role:
command.extend(["--role", self.role])
if self.api_token:
command.extend(["--token", self.api_token])
return command
def _run_json(self, command: list[str]) -> dict[str, Any]:
result = self._run_raw(command[1:], full_command=command)
if result.returncode != 0:
error_type = NbAuthenticationError if self._is_authentication_failure(result) else NbCommandError
raise error_type(self._format_error(command, result))
try:
parsed = self._parse_json_object(result.stdout)
except json.JSONDecodeError as error:
raise NbCommandError(
f"nb returned invalid JSON: {error.msg}\nstdout={self._redact(result.stdout.strip())}"
) from error
return parsed
@staticmethod
def _parse_json_object(output: str) -> dict[str, Any]:
stripped = output.strip()
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
parsed = None
if parsed is not None:
if not isinstance(parsed, dict):
raise NbCommandError("nb returned JSON that is not an object")
return parsed
decoder = json.JSONDecoder()
candidates: list[tuple[int, int, dict[str, Any]]] = []
for start, character in enumerate(output):
if character != "{":
continue
try:
value, end = decoder.raw_decode(output, start)
except json.JSONDecodeError:
continue
if isinstance(value, dict):
candidates.append((start, end, value))
outer_candidates = [
candidate
for candidate in candidates
if not any(
other_start <= candidate[0]
and other_end >= candidate[1]
and (other_start, other_end) != (candidate[0], candidate[1])
for other_start, other_end, _ in candidates
)
]
if len(outer_candidates) != 1:
raise json.JSONDecodeError("could not locate one complete JSON object", output, 0)
return outer_candidates[0][2]
def _run_raw(
self,
args: list[str],
*,
full_command: list[str] | None = None,
) -> subprocess.CompletedProcess[str]:
command = full_command or [self.nb_bin, *args]
env = os.environ.copy()
env["NB_CLI_LOG_DISABLED"] = "1"
env["NB_SKIP_STARTUP_UPDATE"] = "1"
self._prepend_nb_bin_dir_to_path(env)
return self.runner(
command,
cwd=str(self.cwd) if self.cwd else None,
env=env,
timeout=self.timeout,
capture_output=True,
text=True,
check=False,
)
def _prepend_nb_bin_dir_to_path(self, env: dict[str, str]) -> None:
nb_path = Path(self.nb_bin).expanduser()
if not nb_path.is_absolute():
return
nb_dir = str(nb_path.parent)
existing_path = env.get("PATH", "")
parts = [part for part in existing_path.split(os.pathsep) if part]
if nb_dir in parts:
return
env["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "")
def _format_error(self, command: list[str], result: subprocess.CompletedProcess[str]) -> str:
return (
f"nb command failed with exit code {result.returncode}: {self._redact_command(command)}\n"
f"stdout={self._redact(result.stdout.strip())}\n"
f"stderr={self._redact(result.stderr.strip())}"
)
@staticmethod
def _is_authentication_failure(result: subprocess.CompletedProcess[str]) -> bool:
output = f"{result.stdout}\n{result.stderr}"
if re.search(r"\bstatus\s+401\b", output, flags=re.IGNORECASE):
return True
upper_output = output.upper()
return any(
code in upper_output
for code in (
"EMPTY_TOKEN",
"INVALID_TOKEN",
"EXPIRED_TOKEN",
"BLOCKED_TOKEN",
"EXPIRED_SESSION",
"NOT_EXIST_USER",
)
)
def _redact_command(self, command: Sequence[str]) -> str:
redacted: list[str] = []
skip_next = False
for token in command:
if skip_next:
redacted.append("<redacted>")
skip_next = False
continue
redacted.append(token)
if token == "--token":
skip_next = True
return " ".join(redacted)
def _redact(self, value: str) -> str:
redacted = value
if self.api_token:
redacted = redacted.replace(self.api_token, "<redacted>")
return redacted
@staticmethod
def _append_json(command: list[str], flag: str, value: Any) -> None:
if value is None or value == {}:
return
command.extend([flag, json.dumps(value, ensure_ascii=False, separators=(",", ":"))])
@staticmethod
def _append_json_or_scalar(command: list[str], flag: str, value: Any) -> None:
if value is None:
return
if isinstance(value, (list, dict)):
command.extend([flag, json.dumps(value, ensure_ascii=False, separators=(",", ":"))])
else:
command.extend([flag, str(value)])
@staticmethod
def _append_repeated(command: list[str], flag: str, values: Sequence[str] | None) -> None:
for value in values or []:
command.extend([flag, str(value)])
@staticmethod
def _append_value(command: list[str], flag: str, value: Any) -> None:
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")
-202
View File
@@ -1,202 +0,0 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
class NocoBaseHttpError(RuntimeError):
def __init__(
self,
status_code: int,
message: str,
*,
authentication_failed: bool | None = None,
) -> None:
self.status_code = status_code
self.authentication_failed = status_code == 401 if authentication_failed is None else authentication_failed
super().__init__(message)
class NocoBaseHttpClient:
"""Small synchronous client for the fixed NocoBase attachment endpoints."""
_PROJECT_RESOURCE = "t_projects"
_ATTACHMENT_FIELD = "t_projects.f_projects_attachments"
def __init__(
self,
api_base_url: str | None = None,
api_token: str | None = None,
timeout: float = 30.0,
transport: httpx.BaseTransport | None = None,
credential_provider: Callable[[], tuple[str, str]] | None = None,
) -> None:
self.api_base_url = api_base_url.strip().rstrip("/") if api_base_url else ""
self.api_token = api_token.strip() if api_token else ""
self._credential_provider = credential_provider
if credential_provider is None and not self.api_base_url:
raise ValueError("NocoBase API base URL must not be blank")
if credential_provider is None and not self.api_token:
raise ValueError("NocoBase API token must not be blank")
if credential_provider is not None and (self.api_base_url or self.api_token):
raise ValueError("Provide static NocoBase credentials or a credential provider, not both")
self._client = httpx.Client(
timeout=timeout,
transport=transport,
)
def upload_attachment(self, path: Path, filename: str, mimetype: str | None) -> dict[str, Any]:
api_base_url, api_token = self._credentials()
self._ensure_project_write_access(api_base_url, api_token)
with Path(path).open("rb") as handle:
response = self._client.post(
f"{api_base_url}/attachments:create",
params={"attachmentField": self._ATTACHMENT_FIELD},
files={"file": (filename, handle, mimetype or "application/octet-stream")},
headers={"Authorization": f"Bearer {api_token}"},
)
payload = self._json_response(response, api_token)
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 _ensure_project_write_access(self, api_base_url: str, api_token: str) -> None:
response = self._client.get(
f"{api_base_url}/roles:check",
headers={"Authorization": f"Bearer {api_token}"},
)
payload = self._json_response(response, api_token)
role_data = payload.get("data")
if not isinstance(role_data, Mapping):
raise NocoBaseHttpError(502, "NocoBase roles:check response did not contain role data")
effective_role = dict(role_data)
meta = payload.get("meta")
data_sources = meta.get("dataSources") if isinstance(meta, Mapping) else None
main_role = data_sources.get("main") if isinstance(data_sources, Mapping) else None
if isinstance(main_role, Mapping):
effective_role.update(main_role)
if any(_role_allows_action(effective_role, self._PROJECT_RESOURCE, action) for action in ("create", "update")):
return
raise NocoBaseHttpError(403, "Current NocoBase role cannot create or update projects")
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)
api_base_url, api_token = self._credentials()
response = self._client.post(
f"{api_base_url}/t_projects/{project_id_segment}/f_projects_attachments:{action}",
json={"tk": list(attachment_ids)},
headers={"Authorization": f"Bearer {api_token}"},
)
self._raise_for_error(response, api_token)
def _json_response(self, response: httpx.Response, api_token: str) -> dict[str, Any]:
self._raise_for_error(response, api_token)
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, api_token: str) -> 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, api_token),
authentication_failed=_is_authentication_failure(response),
)
def _credentials(self) -> tuple[str, str]:
try:
api_base_url, api_token = (
self._credential_provider()
if self._credential_provider is not None
else (self.api_base_url, self.api_token)
)
except NocoBaseHttpError:
raise
except Exception as error:
raise NocoBaseHttpError(401, str(error), authentication_failed=True) from error
normalized_url = str(api_base_url).strip().rstrip("/")
normalized_token = str(api_token).strip()
if not normalized_url or not normalized_token:
raise NocoBaseHttpError(401, "NocoBase CLI environment is not authenticated", authentication_failed=True)
return normalized_url, normalized_token
@staticmethod
def _redact(value: str, api_token: str) -> str:
return value.replace(api_token, "<redacted>")
def _project_id_segment(project_id: Any) -> str:
return quote(validate_project_id(project_id), safe="")
def _role_allows_action(role_data: Mapping[str, Any], resource: str, action: str) -> bool:
if role_data.get("allowAll") is True:
return True
aliases = role_data.get("actionAlias")
resolved_action = aliases.get(action, action) if isinstance(aliases, Mapping) else action
resources = role_data.get("resources")
if isinstance(resources, Sequence) and not isinstance(resources, (str, bytes)) and resource in resources:
actions = role_data.get("actions")
if not isinstance(actions, Mapping):
return False
for action_path in (f"{resource}:{resolved_action}", f"{resource}:{action}"):
if action_path in actions and actions[action_path] is not None and actions[action_path] is not False:
return True
return False
strategy = role_data.get("strategy")
strategy_actions = strategy.get("actions") if isinstance(strategy, Mapping) else None
if not isinstance(strategy_actions, Sequence) or isinstance(strategy_actions, (str, bytes)):
return False
return any(
isinstance(strategy_action, str) and strategy_action.split(":", 1)[0] == resolved_action
for strategy_action in strategy_actions
)
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)
def _is_authentication_failure(response: httpx.Response) -> bool:
if response.status_code == 401:
return True
response_text = response.text.upper()
return any(
code in response_text
for code in (
"EMPTY_TOKEN",
"INVALID_TOKEN",
"EXPIRED_TOKEN",
"BLOCKED_TOKEN",
"EXPIRED_SESSION",
"NOT_EXIST_USER",
)
)
-247
View File
@@ -1,247 +0,0 @@
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.authentication_failed:
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}}
-207
View File
@@ -1,207 +0,0 @@
from __future__ import annotations
import argparse
import os
import tempfile
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import TransportSecuritySettings
from .config import load_config
from .crm import CrmService
from .nb_client import NbClient
from .nocobase_http import NocoBaseHttpClient
from .projects import ProjectService
from .server_runtime import (
DEFAULT_HTTP_HOST,
DEFAULT_HTTP_PATH,
DEFAULT_HTTP_PORT,
DEFAULT_TOKEN_ENV,
ENABLE_UPLOADS_ENV,
BearerTokenAuthMiddleware,
_read_nocobase_environment,
_read_token_from_env,
create_http_app,
)
from .server_tools_core import register_core_tools
from .server_tools_projects import register_project_tools
from .server_tools_records import register_record_tools
from .server_tools_write import register_write_tools
from .tool_errors import _safe_raw_tool_result, _safe_tool_result
from .uploads import UploadEndpoint
ENABLE_CONFIG_TOOLS_ENV = "NOCOBASE_MCP_ENABLE_CONFIG_TOOLS"
__all__ = [
"BearerTokenAuthMiddleware",
"DEFAULT_HTTP_HOST",
"DEFAULT_HTTP_PATH",
"DEFAULT_HTTP_PORT",
"FastMCP",
"_make_write_services",
"_read_nocobase_environment",
"create_http_app",
"create_server",
"run_http_server",
]
def _make_service(config_path: str | Path | None = None) -> CrmService:
config = load_config(config_path)
nb_bin = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip() or "nb"
client = NbClient(env_name=config.env_name, data_source=config.data_source, nb_bin=nb_bin)
return CrmService(config, client)
def _make_write_services(
config_path: str | Path | None = None,
*,
api_base_url: str | None = None,
api_token: str | None = None,
) -> tuple[CrmService, ProjectService, NocoBaseHttpClient]:
config = load_config(config_path)
nb_bin = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip() or "nb"
client = NbClient(
env_name=config.env_name,
data_source=config.data_source,
nb_bin=nb_bin,
api_base_url=api_base_url,
api_token=api_token,
)
if api_base_url and api_token:
http_client = NocoBaseHttpClient(api_base_url, api_token)
else:
http_client = NocoBaseHttpClient(credential_provider=client.environment_credentials)
return CrmService(config, client), ProjectService(config.project, client, http_client), http_client
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,
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 runtime dependencies with: uv sync --locked --no-dev")
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(
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,
)
register_core_tools(
server,
service,
_safe_tool_result,
enable_config_tools=os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1",
)
register_record_tools(server, service, _safe_tool_result, _safe_raw_tool_result)
register_project_tools(server, service, _safe_tool_result, _safe_raw_tool_result)
if not readonly:
register_write_tools(server, project_service, _safe_tool_result)
return server
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)
api_base_url, api_token, max_bytes = _read_nocobase_environment()
crm_service, project_service, http_client = _make_write_services(
config_path,
api_base_url=api_base_url,
api_token=api_token,
)
server = create_server(
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,
)
upload_endpoint = None
if os.environ.get(ENABLE_UPLOADS_ENV, "").strip() == "1":
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")
def main() -> None:
parser = argparse.ArgumentParser(description="Run the local 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")
parser.add_argument(
"--enable-writes",
action="store_true",
help="Expose fixed-schema project create/update tools in local stdio mode.",
)
args = parser.parse_args()
if args.transport == "stdio":
if args.enable_writes:
crm_service, project_service, _http_client = _make_write_services(args.config)
create_server(
crm_service=crm_service,
project_service=project_service,
readonly=False,
).run(transport="stdio")
else:
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()
-150
View File
@@ -1,150 +0,0 @@
from __future__ import annotations
import hmac
import os
from typing import Any
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from .uploads import UploadEndpoint
DEFAULT_HTTP_HOST = "127.0.0.1"
DEFAULT_HTTP_PORT = 10087
DEFAULT_HTTP_PATH = "/mcp"
DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN"
ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS"
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"
API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL"
API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN"
DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
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 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, upload_endpoint: UploadEndpoint | None = None) -> Any:
if upload_endpoint is None:
return BearerTokenAuthMiddleware(server.streamable_http_app(), token)
async def upload_route(request: Any) -> Any:
return await upload_endpoint(request)
mcp_app = server.streamable_http_app()
app = Starlette(
routes=[Route("/uploads", endpoint=upload_route, methods=["POST"]), Mount("/", app=mcp_app)],
lifespan=mcp_app.router.lifespan_context,
)
return BearerTokenAuthMiddleware(app, token)
def read_nocobase_environment() -> tuple[str | None, str | None, int]:
canonical_url = os.environ.get(API_BASE_URL_ENV, "").strip().rstrip("/")
canonical_token = os.environ.get(API_TOKEN_ENV, "").strip()
legacy_url = os.environ.get(UPLOAD_API_BASE_URL_ENV, "").strip().rstrip("/")
legacy_token = os.environ.get(UPLOAD_JWT_ENV, "").strip()
canonical_any = bool(canonical_url or canonical_token)
legacy_any = bool(legacy_url or legacy_token)
if canonical_any and not (canonical_url and canonical_token):
raise RuntimeError(f"{API_BASE_URL_ENV} and {API_TOKEN_ENV} must be set together")
if legacy_any and not (legacy_url and legacy_token):
raise RuntimeError(f"{UPLOAD_API_BASE_URL_ENV} and {UPLOAD_JWT_ENV} must be set together")
if canonical_any and legacy_any and (canonical_url != legacy_url or canonical_token != legacy_token):
raise RuntimeError("Canonical and legacy NocoBase credential environment variables conflict")
if canonical_any:
api_base_url, api_token = canonical_url, canonical_token
elif legacy_any:
api_base_url, api_token = legacy_url, legacy_token
else:
api_base_url, api_token = None, None
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, api_token, max_bytes
_read_token_from_env = read_token_from_env
_read_nocobase_environment = read_nocobase_environment
__all__ = [
"API_BASE_URL_ENV",
"API_TOKEN_ENV",
"BearerTokenAuthMiddleware",
"DEFAULT_HTTP_HOST",
"DEFAULT_HTTP_PATH",
"DEFAULT_HTTP_PORT",
"DEFAULT_TOKEN_ENV",
"DEFAULT_UPLOAD_MAX_BYTES",
"ENABLE_UPLOADS_ENV",
"UPLOAD_API_BASE_URL_ENV",
"UPLOAD_JWT_ENV",
"UPLOAD_MAX_BYTES_ENV",
"create_http_app",
"read_nocobase_environment",
"read_token_from_env",
"_read_nocobase_environment",
"_read_token_from_env",
]
-139
View File
@@ -1,139 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Annotated, Any
from pydantic import Field
SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]]
SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]]
def register_core_tools(server: Any, service: Any, safe_result: SafeResult, *, enable_config_tools: bool) -> None:
@server.tool()
def crm_health() -> dict[str, Any]:
"""Check NocoBase CLI reachability and the configured CRM collection mapping."""
return safe_result(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 safe_result(lambda: 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 user to filter customer ownership and follow-up participation."),
] = None,
) -> dict[str, Any]:
"""Return CRM totals, common customer distributions, and recent follow-ups."""
return safe_result(lambda: 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 safe_result(lambda: 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 safe_result(lambda: service.resolve_owner(query))
@server.tool()
def list_teams(
query: Annotated[str | None, Field(description="Optional team name search.")] = None,
include_members: Annotated[bool, Field(description="Include resolved team members.")] = False,
formal_members_only: Annotated[
bool,
Field(description="When including members, keep only formal employees."),
] = True,
limit: Annotated[int, Field(description="Maximum teams to return.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
) -> dict[str, Any]:
"""List configured CRM teams and optionally their formal members."""
return safe_result(
lambda: service.list_teams(
query,
include_members=include_members,
formal_members_only=formal_members_only,
limit=limit,
offset=offset,
)
)
@server.tool()
def resolve_team(
query: Annotated[str, Field(description="Team name to resolve.")],
) -> dict[str, Any]:
"""Resolve a team name before using it as a CRM filter."""
return safe_result(lambda: service.resolve_team(query))
@server.tool()
def get_team(
team_id: Annotated[Any, Field(description="NocoBase team ID.")],
include_members: Annotated[bool, Field(description="Include team members.")] = True,
formal_members_only: Annotated[
bool,
Field(description="Return only formal employees when members are included."),
] = True,
) -> dict[str, Any]:
"""Get one CRM team, its managers, and optionally its members."""
return safe_result(
lambda: service.get_team(
team_id,
include_members=include_members,
formal_members_only=formal_members_only,
)
)
@server.tool()
def team_crm_overview(
team: Annotated[str, Field(description="Exact or uniquely matching CRM team name.")],
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,
include_member_breakdown: Annotated[
bool,
Field(description="Include per-member customer ownership and follow-up contribution."),
] = True,
) -> dict[str, Any]:
"""Return team totals and member contributions using formal CRM team membership."""
return safe_result(
lambda: service.team_crm_overview(
team,
date_from=date_from,
date_to=date_to,
include_member_breakdown=include_member_breakdown,
)
)
if enable_config_tools:
@server.tool()
def crm_config_template() -> dict[str, Any]:
"""Infer a config.json template from collection field metadata without reading business records."""
return safe_result(service.crm_config_template)
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Annotated, Any
from pydantic import Field
SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]]
SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]]
def register_project_tools(server: Any, service: Any, safe_result: SafeResult, safe_raw_result: SafeRawResult) -> None:
@server.tool()
def list_projects(
customer_id: Annotated[Any | None, Field(description="Optional customer ID.")] = None,
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
stage: Annotated[str | None, Field(description="Optional project stage label or configured value.")] = None,
limit: Annotated[int, Field(description="Maximum projects to return.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
include_raw: Annotated[bool, Field(description="Include original NocoBase project rows.")] = False,
) -> dict[str, Any]:
"""List CRM projects linked to customers, teams, and project stages."""
return safe_raw_result(
include_raw,
lambda: service.list_projects(
customer_id=customer_id,
team=team,
stage=stage,
limit=limit,
offset=offset,
include_raw=include_raw,
),
)
@server.tool()
def get_project(
project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")],
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_followups: Annotated[bool, Field(description="Include related follow-ups.")] = True,
include_updates: Annotated[bool, Field(description="Include project update records.")] = True,
) -> dict[str, Any]:
"""Get a CRM project with its existing NocoBase relations."""
return safe_result(
lambda: service.get_project(
project_id,
include_contacts=include_contacts,
include_followups=include_followups,
include_updates=include_updates,
)
)
@server.tool()
def get_project_timeline(
project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")],
date_from: Annotated[str | None, Field(description="Optional lower event date bound.")] = None,
date_to: Annotated[str | None, Field(description="Optional upper event date bound.")] = None,
) -> dict[str, Any]:
"""Return a chronological CRM project timeline from follow-ups and project updates."""
return safe_result(lambda: service.get_project_timeline(project_id, date_from=date_from, date_to=date_to))
@server.tool()
def list_stale_customers(
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
owner: Annotated[str | None, Field(description="Optional customer owner.")] = None,
inactive_days: Annotated[int, Field(description="Days without a valid follow-up.")] = 30,
as_of: Annotated[str | None, Field(description="Reference date in YYYY-MM-DD format.")] = None,
limit: Annotated[int, Field(description="Maximum customers to return.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
) -> dict[str, Any]:
"""List customers whose last valid follow-up is older than the configured threshold."""
return safe_result(
lambda: service.list_stale_customers(
team=team,
owner=owner,
inactive_days=inactive_days,
as_of=as_of,
limit=limit,
offset=offset,
)
)
-161
View File
@@ -1,161 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Annotated, Any
from pydantic import Field
SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]]
SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]]
def register_record_tools(server: Any, service: Any, safe_result: SafeResult, safe_raw_result: SafeRawResult) -> None:
@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,
team: Annotated[str | None, Field(description="Optional CRM team 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,
include_raw: Annotated[
bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")
] = False,
) -> dict[str, Any]:
"""List CRM customers with compact rows and pagination metadata.
Rows are {id, name, english_name, territorial_name, status, source,
owners: [{id, name}], managers, createdAt, updatedAt}. Empty values are
omitted. A name shown as "#123" means that customer has no name recorded.
"""
return safe_raw_result(
include_raw,
lambda: service.list_customers(
query=query,
status=status,
owner=owner,
team=team,
source=source,
limit=limit,
offset=offset,
include_raw=include_raw,
),
)
@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,
include_raw: Annotated[
bool,
Field(description="When true, include original NocoBase rows under raw. Defaults to false."),
] = False,
) -> dict[str, Any]:
"""Get one customer with its contacts and recent follow-ups.
Returns {customer, contacts, recent_followups, timezone}. The customer is
{id, name, english_name, territorial_name, status, source, owners, managers,
address, intro, tags, ...} using readable keys, never NocoBase field codes.
Contacts are {id, name, title} without phone or email -- call list_contacts
for those. Follow-ups use the same row shape as list_followups. Empty values
are omitted, so a missing key means empty rather than unknown.
"""
return safe_raw_result(
include_raw,
lambda: service.get_customer(
customer_id,
include_contacts=include_contacts,
include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
include_raw=include_raw,
),
)
@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,
include_details: Annotated[
bool,
Field(description="Include contact phone and email. Defaults to true."),
] = True,
include_raw: Annotated[
bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")
] = False,
) -> dict[str, Any]:
"""List CRM contacts as compact rows, optionally filtered by customer.
Rows are {id, name, title, phone, email, customers: [{id, name}],
createdAt, updatedAt}. Set include_details=false to omit phone and email.
Empty values are omitted.
"""
return safe_raw_result(
include_raw,
lambda: service.list_contacts(
customer_id=customer_id,
query=query,
limit=limit,
offset=offset,
include_details=include_details,
include_raw=include_raw,
),
)
@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 participant name, username, email, or English name.")
] = None,
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
project_id: Annotated[Any | None, Field(description="Optional CRM project ID.")] = None,
followup_type: Annotated[str | None, Field(description="Optional exact follow-up type value.")] = None,
record_status: Annotated[str | None, Field(description="Optional exact follow-up record status value.")] = 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,
include_raw: Annotated[
bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")
] = False,
) -> dict[str, Any]:
"""List customer follow-up records as compact rows with pagination metadata.
Rows are {id, followedAt, customers: [{id, name}], contacts, participants,
projects, type, status, location, content}. Content is the full follow-up
text, never truncated. type and status are the display labels; use
crm_filter_options for the codes the followup_type and record_status
filters expect. creator appears only when the submitter is not also a
participant. Empty values are omitted.
"""
return safe_raw_result(
include_raw,
lambda: service.list_followups(
customer_id=customer_id,
contact_id=contact_id,
owner=owner,
team=team,
project_id=project_id,
followup_type=followup_type,
record_status=record_status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
include_raw=include_raw,
),
)
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Annotated, Any
from pydantic import Field
SafeResult = Callable[[Callable[[], dict[str, Any]]], dict[str, Any]]
SafeRawResult = Callable[[bool, Callable[[], dict[str, Any]]], dict[str, Any]]
def register_write_tools(server: Any, project_service: Any, safe_result: SafeResult) -> None:
@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."""
assert project_service is not None
return safe_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."""
assert project_service is not None
return safe_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,
)
)
-84
View File
@@ -1,84 +0,0 @@
from __future__ import annotations
import os
import subprocess
from collections.abc import Callable
from typing import Any
from .nb_client import NbAuthenticationError, NbCommandError
ENABLE_RAW_DATA_ENV = "NOCOBASE_MCP_ENABLE_RAW_DATA"
def safe_tool_result(operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
try:
return operation()
except NbAuthenticationError as error:
return tool_error(
code="nb_auth_failed",
message=str(error),
hint=(
"Ask the local installation Agent to run the secure connect-key flow, then enter your personal "
"NocoBase API Key only in the private local prompt."
),
retryable=False,
)
except NbCommandError as error:
return tool_error(
code="nb_command_failed",
message=str(error),
hint="Check nb env status, NocoBase ACL, collection names, and query parameters.",
retryable=False,
)
except subprocess.TimeoutExpired as error:
return tool_error(
code="nb_command_timeout",
message=str(error),
hint="The NocoBase CLI call timed out. Retry with a smaller query or check NocoBase responsiveness.",
retryable=True,
)
except FileNotFoundError as error:
return tool_error(
code="nb_not_found",
message=str(error),
hint="Set NOCOBASE_MCP_NB_BIN to the absolute nb path, then restart with scripts/mcp_service.sh start.",
retryable=False,
)
except Exception as error:
return tool_error(
code="tool_error",
message=str(error),
hint="Check MCP server logs and the CRM mapping config.",
retryable=False,
)
def safe_raw_tool_result(include_raw: bool, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
if include_raw and os.environ.get(ENABLE_RAW_DATA_ENV, "").strip() != "1":
return tool_error(
code="raw_data_disabled",
message="Raw NocoBase rows are disabled for this local MCP installation.",
hint=f"Keep include_raw=false, or set {ENABLE_RAW_DATA_ENV}=1 after approving the data exposure policy.",
retryable=False,
)
return safe_tool_result(operation)
def tool_error(*, code: str, message: str, hint: str, retryable: bool) -> dict[str, Any]:
return {"error": {"code": code, "message": message, "hint": hint, "retryable": retryable}}
# Compatibility aliases for internal imports and older tests.
_safe_tool_result = safe_tool_result
_safe_raw_tool_result = safe_raw_tool_result
_tool_error = tool_error
__all__ = [
"ENABLE_RAW_DATA_ENV",
"safe_tool_result",
"safe_raw_tool_result",
"tool_error",
"_safe_tool_result",
"_safe_raw_tool_result",
"_tool_error",
]
-161
View File
@@ -1,161 +0,0 @@
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:
if error.authentication_failed:
return _error_response("nb_upload_unauthorized", "NocoBase attachment upload failed", 502)
if error.status_code == 403:
return _error_response(
"nb_upload_forbidden",
"The current NocoBase role cannot upload project attachments",
403,
)
return _error_response("nb_upload_failed", "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)
+1 -10
View File
@@ -3,17 +3,8 @@
"mcp": {
"nocobase-crm": {
"type": "local",
"command": [
"uv",
"run",
"nocobase-crm-mcp",
"--config",
"config.json"
],
"command": ["node", "dist/index.js", "--config", "config.json"],
"cwd": ".",
"environment": {
"UV_CACHE_DIR": "/tmp/uv-cache"
},
"enabled": true
}
}
+2008
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.0",
"description": "Local NocoBase CRM MCP server using each user's personal Authorize Key",
"type": "module",
"private": true,
"license": "UNLICENSED",
"bin": {
"nocobase-crm-mcp": "dist/index.js"
},
"files": [
"dist/",
"config.example.json",
"INSTALL.md",
"README.md",
"install-manifest.json"
],
"engines": {
"node": ">=20.11"
},
"scripts": {
"build": "node scripts/build.mjs",
"check": "biome check . && tsc --noEmit",
"format": "biome check --write .",
"test": "tsx --test test/*.test.ts",
"test:coverage": "tsx --test --experimental-test-coverage test/*.test.ts",
"prepack": "npm run build",
"pack:release": "npm pack --pack-destination release"
},
"devDependencies": {
"@biomejs/biome": "2.5.8",
"@modelcontextprotocol/sdk": "1.30.0",
"@types/node": "^22.19.0",
"esbuild": "0.28.2",
"tsx": "4.23.12",
"typescript": "^5.9.3",
"zod": "4.4.3"
}
}
-47
View File
@@ -1,47 +0,0 @@
[project]
name = "nocobase-crm-mcp"
version = "0.2.0"
description = "Local, ACL-aware MCP server for NocoBase CRM through each user's nb CLI login."
requires-python = ">=3.12"
dependencies = [
"cryptography>=50,<51",
"httpx>=0.27,<1",
"mcp>=1.27,<2",
"python-multipart>=0.0.9,<1",
]
[dependency-groups]
dev = [
"mypy>=1.17,<2",
"pip-audit>=2.9,<3",
"ruff>=0.12,<1",
]
[project.scripts]
nocobase-crm-mcp = "nocobase_crm_mcp.server:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["nocobase_crm_mcp*"]
[tool.ruff]
target-version = "py312"
line-length = 120
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "UP"]
[tool.ruff.lint.per-file-ignores]
"tests/test_mcp_service.py" = ["E402"]
[tool.mypy]
python_version = "3.12"
files = ["nocobase_crm_mcp"]
check_untyped_defs = true
ignore_missing_imports = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
-52
View File
@@ -1,52 +0,0 @@
from __future__ import annotations
from agent_installer.auth import (
connect_with_personal_key,
normalize_api_base_url,
)
from agent_installer.cli import build_parser, main
from agent_installer.common import (
DEFAULT_ENV_NAME,
ENV_NAME_PATTERN,
MIN_PYTHON,
REQUIRED_PROJECT_FILES,
SERVER_NAME,
Check,
InstallerError,
Runner,
Which,
)
from agent_installer.diagnostics import doctor_project
from agent_installer.flow import install_project
from agent_installer.project import (
inspect_project,
prepare_project,
render_client_config,
set_config_env,
)
__all__ = [
"Check",
"DEFAULT_ENV_NAME",
"ENV_NAME_PATTERN",
"InstallerError",
"MIN_PYTHON",
"REQUIRED_PROJECT_FILES",
"Runner",
"SERVER_NAME",
"Which",
"build_parser",
"connect_with_personal_key",
"doctor_project",
"inspect_project",
"install_project",
"main",
"normalize_api_base_url",
"prepare_project",
"render_client_config",
"set_config_env",
]
if __name__ == "__main__":
raise SystemExit(main())
-17
View File
@@ -1,17 +0,0 @@
from .auth import connect_with_personal_key, normalize_api_base_url
from .cli import main
from .diagnostics import doctor_project
from .flow import install_project
from .project import inspect_project, prepare_project, render_client_config, set_config_env
__all__ = [
"connect_with_personal_key",
"doctor_project",
"inspect_project",
"install_project",
"main",
"normalize_api_base_url",
"prepare_project",
"render_client_config",
"set_config_env",
]
-230
View File
@@ -1,230 +0,0 @@
from __future__ import annotations
from .common import (
ANSI_ESCAPE_PATTERN,
DEFAULT_ENV_NAME,
ENV_NAME_PATTERN,
Any,
Callable,
InstallerError,
Path,
Runner,
Which,
_project_root,
json,
os,
re,
shutil,
subprocess,
sys,
urlsplit,
urlunsplit,
)
from .project import set_config_env
def normalize_api_base_url(value: str) -> str:
raw = value.strip()
if not raw:
raise InstallerError("NocoBase API base URL is required")
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise InstallerError("NocoBase URL must be an absolute http:// or https:// URL")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise InstallerError("NocoBase URL must not contain credentials, a query string, or a fragment")
hostname = parsed.hostname.lower()
is_local = hostname == "localhost" or hostname == "::1" or hostname.startswith("127.")
if parsed.scheme != "https" and not is_local:
raise InstallerError("A remote NocoBase API Key may only be sent to an https:// endpoint")
path = parsed.path.rstrip("/")
if not path.endswith("/api"):
path = f"{path}/api" if path else "/api"
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
def _parse_cli_json(output: str) -> dict[str, Any]:
decoder = json.JSONDecoder()
candidates: list[tuple[int, int, dict[str, Any]]] = []
for start, character in enumerate(output):
if character != "{":
continue
try:
value, end = decoder.raw_decode(output, start)
except json.JSONDecodeError:
continue
if isinstance(value, dict):
candidates.append((start, end, value))
outer_candidates = [
candidate
for candidate in candidates
if not any(
other_start <= candidate[0]
and other_end >= candidate[1]
and (other_start, other_end) != (candidate[0], candidate[1])
for other_start, other_end, _value in candidates
)
]
if len(outer_candidates) != 1:
raise InstallerError("nb env info did not return one JSON object")
return outer_candidates[0][2]
def _nb_command_environment(nb_bin: str) -> dict[str, str]:
environment = os.environ.copy()
environment["NB_CLI_LOG_DISABLED"] = "1"
environment["NB_SKIP_STARTUP_UPDATE"] = "1"
nb_path = Path(nb_bin)
if nb_path.is_absolute():
nb_dir = str(nb_path.parent)
existing_path = environment.get("PATH", "")
parts = [part for part in existing_path.split(os.pathsep) if part]
if nb_dir not in parts:
environment["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "")
return environment
def _read_nb_environment(
root: Path,
env_name: str,
*,
nb_bin: str,
runner: Runner,
) -> dict[str, Any] | None:
command = [nb_bin, "env", "info", env_name, "--json"]
try:
result = runner(
command,
cwd=str(root),
env=_nb_command_environment(nb_bin),
capture_output=True,
text=True,
check=False,
)
except OSError as error:
raise InstallerError(f"Could not execute nb env info: {error}") from error
if result.returncode == 0:
return _parse_cli_json(result.stdout)
diagnostic = ANSI_ESCAPE_PATTERN.sub("", f"{result.stdout}\n{result.stderr}")
if "is not configured" in diagnostic:
return None
raise InstallerError(
f"nb env info failed with exit code {result.returncode}; inspect the nb CLI diagnostic locally"
)
def _api_details(payload: dict[str, Any]) -> tuple[str | None, str | None]:
api = payload.get("api")
auth = api.get("auth") if isinstance(api, dict) else None
api_base_url = api.get("apiBaseUrl") if isinstance(api, dict) else None
auth_type = auth.get("type") if isinstance(auth, dict) else None
normalized_url = api_base_url.strip().rstrip("/") if isinstance(api_base_url, str) else None
normalized_type = auth_type.strip() if isinstance(auth_type, str) else None
return normalized_url, normalized_type
def _suggest_env_name(api_base_url: str) -> str:
hostname = urlsplit(api_base_url).hostname or "server"
suffix = re.sub(r"[^a-z0-9]+", "-", hostname.lower()).strip("-")[:32] or "server"
return f"{DEFAULT_ENV_NAME}-{suffix}"
def connect_with_personal_key(
project_root: str | Path | None,
api_base_url: str,
*,
env_name: str = DEFAULT_ENV_NAME,
runner: Runner = subprocess.run,
which: Which = shutil.which,
stdin_isatty: Callable[[], bool] = sys.stdin.isatty,
) -> dict[str, Any]:
"""Bind a personal API Key through nb's private TTY prompt.
The key is never accepted by this function, placed in argv, inherited from
the process environment, captured, printed, or returned. The child nb CLI
reads it directly from the user-controlled terminal.
"""
if not ENV_NAME_PATTERN.fullmatch(env_name):
raise InstallerError(
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
)
if not stdin_isatty():
raise InstallerError(
"Secure API Key entry requires a user-controlled local terminal. "
"Do not paste the Key into Agent chat or pass it with --access-token."
)
root = _project_root(project_root)
normalized_url = normalize_api_base_url(api_base_url)
discovered_nb = which("nb")
if not discovered_nb:
raise InstallerError("nb was not found on PATH")
nb_bin = os.path.abspath(discovered_nb)
existing = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner)
if existing is None:
interactive_command = [
nb_bin,
"env",
"add",
env_name,
"--api-base-url",
normalized_url,
"--auth-type",
"token",
]
action = "created"
else:
existing_url, _auth_type = _api_details(existing)
if existing_url != normalized_url.rstrip("/"):
suggestion = _suggest_env_name(normalized_url)
raise InstallerError(
f"Environment {env_name!r} already targets {existing_url or 'an unconfigured endpoint'!r}; "
f"use a new name such as {suggestion!r} instead of overwriting it"
)
interactive_command = [nb_bin, "env", "auth", env_name, "--auth-type", "token"]
action = "reauthenticated"
try:
result = runner(
interactive_command,
cwd=str(root),
env=_nb_command_environment(nb_bin),
text=True,
check=False,
)
except OSError as error:
raise InstallerError(f"Could not execute the interactive nb Key prompt: {error}") from error
if result.returncode != 0:
raise InstallerError(
f"Interactive API Key authentication failed with exit code {result.returncode}; "
"revoke the exposed or invalid Key if necessary and retry through the installer"
)
verified = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner)
if verified is None:
raise InstallerError("nb reported success but the saved environment could not be read back")
verified_url, auth_type = _api_details(verified)
if verified_url != normalized_url.rstrip("/") or auth_type != "token":
raise InstallerError("The saved nb environment did not verify as token authentication for the requested URL")
list_result = runner(
[nb_bin, "env", "list"],
cwd=str(root),
env=_nb_command_environment(nb_bin),
capture_output=True,
text=True,
check=False,
)
if list_result.returncode != 0:
raise InstallerError("API Key was saved, but nb env list readback failed")
config_result = set_config_env(root, env_name)
return {
"ok": True,
"action": action,
"env": env_name,
"apiBaseUrl": normalized_url,
"authType": "token",
"keyExposure": "private-tty-only",
"configChanged": config_result["changed"],
"configBackupPath": config_result["backupPath"],
}
-107
View File
@@ -1,107 +0,0 @@
from __future__ import annotations
from .auth import connect_with_personal_key
from .common import (
DEFAULT_ENV_NAME,
Any,
InstallerError,
Path,
Sequence,
argparse,
json,
)
from .diagnostics import doctor_project
from .flow import install_project
from .project import inspect_project, prepare_project, render_client_config, set_config_env
def _print_result(payload: dict[str, Any], *, as_json: bool) -> None:
if as_json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return
print(f"Overall: {'ready' if payload.get('ok') else 'action required'}")
if "projectRoot" in payload:
print(f"Project root: {payload['projectRoot']}")
for check in payload.get("checks", []):
marker = "PASS" if check["ok"] else ("FAIL" if check["required"] else "INFO")
print(f"[{marker}] {check['name']}: {check['detail']}")
if "status" in payload:
print(f"Status: {payload['status']}")
if "clientConfig" in payload:
print("Client configuration:")
print(json.dumps(payload["clientConfig"], ensure_ascii=False, indent=2))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Prepare and inspect an agent-guided NocoBase CRM MCP installation.")
parser.add_argument("--project-root", type=Path, default=None)
subparsers = parser.add_subparsers(dest="command", required=True)
install = subparsers.add_parser("install", help="Run the complete guided local installation flow.")
install.add_argument("--api-base-url", required=True)
install.add_argument("--env-name", default=DEFAULT_ENV_NAME)
install.add_argument("--enable-writes", action="store_true")
install.add_argument("--json", action="store_true", dest="as_json")
for name in ("inspect", "prepare", "doctor"):
command = subparsers.add_parser(name)
command.add_argument("--json", action="store_true", dest="as_json")
set_env = subparsers.add_parser("set-env")
set_env.add_argument("env_name")
set_env.add_argument("--json", action="store_true", dest="as_json")
connect_key = subparsers.add_parser("connect-key")
connect_key.add_argument("--api-base-url", required=True)
connect_key.add_argument("--env-name", default=DEFAULT_ENV_NAME)
connect_key.add_argument("--json", action="store_true", dest="as_json")
client_config = subparsers.add_parser("client-config")
client_config.add_argument("--enable-writes", action="store_true")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "install":
payload = install_project(
args.project_root,
args.api_base_url,
env_name=args.env_name,
enable_writes=args.enable_writes,
)
_print_result(payload, as_json=args.as_json)
return 0 if payload["ok"] else 1
if args.command == "inspect":
payload = inspect_project(args.project_root)
_print_result(payload, as_json=args.as_json)
return 0 if payload["ok"] else 1
if args.command == "prepare":
payload = prepare_project(args.project_root)
_print_result(payload, as_json=args.as_json)
return 0
if args.command == "set-env":
payload = set_config_env(args.project_root, args.env_name)
_print_result(payload, as_json=args.as_json)
return 0
if args.command == "connect-key":
payload = connect_with_personal_key(
args.project_root,
args.api_base_url,
env_name=args.env_name,
)
_print_result(payload, as_json=args.as_json)
return 0
if args.command == "client-config":
print(json.dumps(render_client_config(args.project_root, enable_writes=args.enable_writes), indent=2))
return 0
if args.command == "doctor":
payload = doctor_project(args.project_root)
_print_result(payload, as_json=args.as_json)
return 0 if payload["ok"] else 1
except InstallerError as error:
print(json.dumps({"ok": False, "error": {"code": "install_step_failed", "message": str(error)}}))
return 1
raise AssertionError(f"Unhandled command: {args.command}")
-89
View File
@@ -1,89 +0,0 @@
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from collections.abc import Callable, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
MIN_PYTHON = (3, 12)
DEFAULT_ENV_NAME = "nb-mcp"
SERVER_NAME = "nocobase-crm"
REQUIRED_PROJECT_FILES = ("pyproject.toml", "uv.lock", "config.example.json")
ENV_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
Which = Callable[[str], str | None]
Runner = Callable[..., subprocess.CompletedProcess[str]]
class InstallerError(RuntimeError):
"""Raised when a deterministic installation step cannot finish safely."""
@dataclass(frozen=True)
class Check:
name: str
ok: bool
required: bool
detail: str
def _project_root(path: str | Path | None) -> Path:
candidate = Path(path) if path is not None else Path(__file__).resolve().parent.parent
return candidate.expanduser().resolve()
def _load_json_object(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise InstallerError(f"Could not read valid JSON from {path}: {error}") from error
if not isinstance(value, dict):
raise InstallerError(f"Expected a JSON object in {path}")
return value
__all__ = [
"ANSI_ESCAPE_PATTERN",
"Any",
"Callable",
"Check",
"DEFAULT_ENV_NAME",
"ENV_NAME_PATTERN",
"InstallerError",
"MIN_PYTHON",
"Path",
"REQUIRED_PROJECT_FILES",
"Runner",
"SERVER_NAME",
"Sequence",
"Which",
"_load_json_object",
"_project_root",
"argparse",
"asdict",
"json",
"os",
"re",
"shutil",
"subprocess",
"sys",
"tempfile",
"urlsplit",
"urlunsplit",
]
-83
View File
@@ -1,83 +0,0 @@
from __future__ import annotations
from .common import (
Any,
Check,
InstallerError,
Path,
Runner,
Which,
_load_json_object,
_project_root,
asdict,
shutil,
subprocess,
)
from .project import _venv_python_path, inspect_project
def doctor_project(
project_root: str | Path | None = None,
*,
runner: Runner = subprocess.run,
which: Which = shutil.which,
python_version: tuple[int, int, int] | None = None,
) -> dict[str, Any]:
root = _project_root(project_root)
inspection = inspect_project(root, which=which, python_version=python_version)
checks = [Check(**item) for item in inspection["checks"]]
config_path = root / "config.json"
if config_path.is_file():
try:
config = _load_json_object(config_path)
env_name = config.get("env")
config_ok = isinstance(env_name, str) and bool(env_name.strip())
detail = f"config env is {env_name!r}" if config_ok else "config.json has no non-empty env"
except InstallerError as error:
config_ok = False
detail = str(error)
else:
config_ok = False
detail = "config.json is missing; run prepare"
checks.append(Check("configured_env", config_ok, True, detail))
uv_bin = which("uv")
if uv_bin:
commands = (
[uv_bin, "lock", "--check"],
[
str(_venv_python_path(root)),
"-c",
"import mcp; import nocobase_crm_mcp",
],
)
labels = ("lockfile", "python_environment")
for label, command in zip(labels, commands, strict=True):
try:
result = runner(command, cwd=str(root), capture_output=True, text=True, check=False)
ok = result.returncode == 0
detail = "passed" if ok else f"failed with exit code {result.returncode}"
except OSError as error:
ok = False
detail = str(error)
checks.append(Check(label, ok, True, detail))
else:
checks.extend(
[
Check("lockfile", False, True, "uv was not found on PATH"),
Check("python_environment", False, True, "uv was not found on PATH"),
]
)
return {
"ok": all(check.ok for check in checks if check.required),
"scope": "local-installation",
"projectRoot": str(root),
"checks": [asdict(check) for check in checks],
"remainingVerification": [
"Have the installation Agent verify the saved env through nb CLI's default redacted info output.",
"Register the generated stdio MCP configuration in the user's client.",
"Call the MCP tool crm_health and require ok=true.",
],
}
-107
View File
@@ -1,107 +0,0 @@
from __future__ import annotations
from .auth import connect_with_personal_key, normalize_api_base_url
from .common import (
DEFAULT_ENV_NAME,
ENV_NAME_PATTERN,
SERVER_NAME,
Any,
Callable,
InstallerError,
Path,
Runner,
Which,
_project_root,
shutil,
subprocess,
sys,
)
from .diagnostics import doctor_project
from .project import inspect_project, prepare_project, render_client_config
def install_project(
project_root: str | Path | None,
api_base_url: str,
*,
env_name: str = DEFAULT_ENV_NAME,
enable_writes: bool = False,
runner: Runner = subprocess.run,
which: Which = shutil.which,
python_version: tuple[int, int, int] | None = None,
stdin_isatty: Callable[[], bool] = sys.stdin.isatty,
) -> dict[str, Any]:
"""Prepare, authenticate, diagnose, and describe one local installation.
The returned client configuration is intentionally not written here: the
installation Agent must merge it into its own client-specific format after
showing the destination and creating a recoverable backup.
"""
root = _project_root(project_root)
normalized_url = normalize_api_base_url(api_base_url)
if not ENV_NAME_PATTERN.fullmatch(env_name):
raise InstallerError(
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
)
if not stdin_isatty():
raise InstallerError(
"Secure API Key entry requires a user-controlled local terminal. "
"Do not paste the Key into Agent chat or pass it with --access-token."
)
inspection = inspect_project(root, which=which, python_version=python_version)
failed_prerequisites = [check["detail"] for check in inspection["checks"] if check["required"] and not check["ok"]]
if failed_prerequisites:
raise InstallerError("Installation prerequisites are not ready: " + "; ".join(failed_prerequisites))
prepared = prepare_project(
root,
runner=runner,
which=which,
python_version=python_version,
)
authentication = connect_with_personal_key(
root,
normalized_url,
env_name=env_name,
runner=runner,
which=which,
stdin_isatty=stdin_isatty,
)
diagnosis = doctor_project(
root,
runner=runner,
which=which,
python_version=python_version,
)
client_config = render_client_config(root, enable_writes=enable_writes, which=which)
local_ready = bool(diagnosis["ok"])
return {
"ok": local_ready,
"status": "ready-for-client-registration" if local_ready else "local-checks-failed",
"projectRoot": str(root),
"mode": "project-writes" if enable_writes else "read-only",
"prepared": {
"environmentPath": prepared["environmentPath"],
"dependencyProfile": prepared["dependencies"],
"configCreated": prepared["configCreated"],
},
"authentication": authentication,
"checks": diagnosis["checks"],
"clientConfig": client_config,
"clientRegistration": {
"required": True,
"serverName": SERVER_NAME,
"backupRequired": True,
"action": "merge the generated server entry, reload the client, then call crm_health",
},
"remainingActions": [
"Merge clientConfig into the current MCP client without replacing other servers.",
"Reload or restart the MCP client.",
"Call crm_health and require ok=true before reporting installation complete.",
],
}
__all__ = ["install_project"]
-212
View File
@@ -1,212 +0,0 @@
from __future__ import annotations
from .common import (
ENV_NAME_PATTERN,
MIN_PYTHON,
REQUIRED_PROJECT_FILES,
SERVER_NAME,
Any,
Check,
InstallerError,
Path,
Runner,
Which,
_load_json_object,
_project_root,
asdict,
json,
os,
shutil,
subprocess,
sys,
tempfile,
)
def inspect_project(
project_root: str | Path | None = None,
*,
which: Which = shutil.which,
python_version: tuple[int, int, int] | None = None,
) -> dict[str, Any]:
root = _project_root(project_root)
version = python_version or (sys.version_info.major, sys.version_info.minor, sys.version_info.micro)
commands = {name: which(name) for name in ("git", "uv", "nb")}
missing_files = [name for name in REQUIRED_PROJECT_FILES if not (root / name).is_file()]
checks = [
Check(
"python",
version[:2] >= MIN_PYTHON,
True,
f"Python {version[0]}.{version[1]}.{version[2]} (requires >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]})",
),
Check("git", bool(commands["git"]), True, commands["git"] or "git was not found on PATH"),
Check("uv", bool(commands["uv"]), True, commands["uv"] or "uv was not found on PATH"),
Check("nb", bool(commands["nb"]), True, commands["nb"] or "nb was not found on PATH"),
Check(
"project_files",
not missing_files,
True,
"required project files are present" if not missing_files else f"missing: {', '.join(missing_files)}",
),
Check(
"config",
(root / "config.json").is_file(),
False,
"config.json is present" if (root / "config.json").is_file() else "config.json will be created by prepare",
),
]
return {
"ok": all(check.ok for check in checks if check.required),
"projectRoot": str(root),
"checks": [asdict(check) for check in checks],
}
def _require_prepare_prerequisites(
root: Path,
*,
which: Which,
python_version: tuple[int, int, int],
) -> str:
inspection = inspect_project(root, which=which, python_version=python_version)
required = {check["name"]: check for check in inspection["checks"] if check["required"]}
blocking = [required[name]["detail"] for name in ("python", "uv", "project_files") if not required[name]["ok"]]
if blocking:
raise InstallerError("; ".join(blocking))
uv_bin = which("uv")
if not uv_bin:
raise InstallerError("uv was not found on PATH")
return os.path.abspath(uv_bin)
def prepare_project(
project_root: str | Path | None = None,
*,
runner: Runner = subprocess.run,
which: Which = shutil.which,
python_version: tuple[int, int, int] | None = None,
) -> dict[str, Any]:
root = _project_root(project_root)
version = python_version or (sys.version_info.major, sys.version_info.minor, sys.version_info.micro)
uv_bin = _require_prepare_prerequisites(root, which=which, python_version=version)
example_path = root / "config.example.json"
config_path = root / "config.json"
_load_json_object(example_path)
created_config = False
if config_path.exists():
_load_json_object(config_path)
else:
shutil.copy2(example_path, config_path)
created_config = True
venv_path = root / ".venv"
command = [uv_bin, "sync", "--locked", "--no-dev", "--python", f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"]
command_environment = os.environ.copy()
command_environment["UV_PROJECT_ENVIRONMENT"] = str(venv_path)
try:
result = runner(
command,
cwd=str(root),
env=command_environment,
capture_output=True,
text=True,
check=False,
)
except OSError as error:
raise InstallerError(f"Could not execute uv sync: {error}") from error
if result.returncode != 0:
raise InstallerError(
f"uv sync --locked failed with exit code {result.returncode}; rerun it directly to inspect package-manager output"
)
return {
"ok": True,
"projectRoot": str(root),
"configPath": str(config_path),
"configCreated": created_config,
"dependencies": "locked-runtime-only",
"environmentPath": str(venv_path),
}
def set_config_env(project_root: str | Path | None, env_name: str) -> dict[str, Any]:
if not ENV_NAME_PATTERN.fullmatch(env_name):
raise InstallerError(
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
)
root = _project_root(project_root)
config_path = root / "config.json"
config = _load_json_object(config_path)
previous = config.get("env")
if previous == env_name:
return {
"ok": True,
"changed": False,
"configPath": str(config_path),
"env": env_name,
"backupPath": None,
}
backup_path = root / "config.json.pre-agent-install.bak"
if not backup_path.exists():
shutil.copy2(config_path, backup_path)
config["env"] = env_name
fd, temporary_name = tempfile.mkstemp(prefix=".config.json.", dir=root)
temporary_path = Path(temporary_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(config, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temporary_path, config_path)
finally:
if temporary_path.exists():
temporary_path.unlink()
return {
"ok": True,
"changed": True,
"configPath": str(config_path),
"env": env_name,
"previousEnv": previous,
"backupPath": str(backup_path),
}
def render_client_config(
project_root: str | Path | None = None,
*,
enable_writes: bool = False,
which: Which = shutil.which,
os_name: str | None = None,
) -> dict[str, Any]:
root = _project_root(project_root)
nb_bin = which("nb") or "nb"
if os.path.isabs(nb_bin):
nb_bin = os.path.abspath(nb_bin)
python_bin = _venv_python_path(root, os_name=os_name)
args = [
"-m",
"nocobase_crm_mcp.server",
"--config",
str(root / "config.json"),
]
if enable_writes:
args.append("--enable-writes")
return {
"mcpServers": {
SERVER_NAME: {
"command": str(python_bin),
"args": args,
"env": {"NOCOBASE_MCP_NB_BIN": nb_bin},
}
}
}
def _venv_python_path(root: Path, *, os_name: str | None = None) -> Path:
if (os_name or os.name) == "nt":
return root / ".venv" / "Scripts" / "python.exe"
return root / ".venv" / "bin" / "python"
+19
View File
@@ -0,0 +1,19 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { build } from "esbuild";
await mkdir("dist", { recursive: true });
await build({
entryPoints: ["src/index.ts"],
outfile: "dist/index.js",
bundle: true,
platform: "node",
format: "esm",
target: "node20",
sourcemap: true,
legalComments: "none",
banner: { js: "#!/usr/bin/env node" },
});
await chmod("dist/index.js", 0o755);
// A legacy Python build may have left dist/.gitignore containing "*". npm uses
// this narrower file to include only the new bundled runtime in Release assets.
await writeFile("dist/.npmignore", "*\n!index.js\n!index.js.map\n", "utf8");
-99
View File
@@ -1,99 +0,0 @@
from __future__ import annotations
from mcp_service_cli import main
from mcp_service_config import (
API_BASE_URL_ENV,
API_TOKEN_ENV,
DEFAULT_CONFIG_PATH,
DEFAULT_HOST,
DEFAULT_NB_BIN,
DEFAULT_NB_ENV,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_TOKEN_BYTES,
DEFAULT_UPLOAD_MAX_BYTES,
DEFAULT_UV_CACHE_DIR,
ENABLE_UPLOADS_ENV,
ENV_RELATIVE_PATH,
LEGACY_API_BASE_URL_ENV,
LEGACY_API_TOKEN_ENV,
MANAGED_ENV_NAMES,
SERVICE_NAME,
ServiceConfig,
ServicePaths,
find_uv_path,
read_service_env_file,
render_service_unit,
resolve_service_paths,
saved_service_config,
systemd_quote,
write_token_env_file,
)
from mcp_service_runtime import (
detect_remote_hosts,
find_nb_path,
generate_token,
new_token,
probe_http_service,
render_remote_config,
restart_service_if_installed,
run_systemctl,
service_state,
service_status,
show_config,
start_service,
stop_service,
uninstall_service,
wait_for_http_service,
write_service_unit,
)
__all__ = [
"SERVICE_NAME",
"ENV_RELATIVE_PATH",
"DEFAULT_HOST",
"DEFAULT_PORT",
"DEFAULT_PATH",
"DEFAULT_NB_ENV",
"DEFAULT_CONFIG_PATH",
"DEFAULT_NB_BIN",
"DEFAULT_TOKEN_BYTES",
"DEFAULT_UV_CACHE_DIR",
"DEFAULT_UPLOAD_MAX_BYTES",
"ENABLE_UPLOADS_ENV",
"API_BASE_URL_ENV",
"API_TOKEN_ENV",
"LEGACY_API_BASE_URL_ENV",
"LEGACY_API_TOKEN_ENV",
"MANAGED_ENV_NAMES",
"ServicePaths",
"ServiceConfig",
"systemd_quote",
"find_uv_path",
"resolve_service_paths",
"render_service_unit",
"write_token_env_file",
"read_service_env_file",
"saved_service_config",
"generate_token",
"find_nb_path",
"write_service_unit",
"detect_remote_hosts",
"render_remote_config",
"show_config",
"run_systemctl",
"service_state",
"probe_http_service",
"wait_for_http_service",
"restart_service_if_installed",
"new_token",
"start_service",
"stop_service",
"uninstall_service",
"service_status",
"main",
]
if __name__ == "__main__":
raise SystemExit(main())
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ $# -eq 0 ]]; then
exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py" --help
fi
COMMAND="$1"
shift
case "$COMMAND" in
new-token | start | show-config | status | uninstall)
exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py" "$COMMAND" --project-root "$PROJECT_ROOT" "$@"
;;
*)
exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py" "$COMMAND" "$@"
;;
esac
-89
View File
@@ -1,89 +0,0 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
from mcp_service_config import (
DEFAULT_CONFIG_PATH,
DEFAULT_HOST,
DEFAULT_NB_ENV,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_TOKEN_BYTES,
SERVICE_NAME,
)
from mcp_service_runtime import new_token, service_status, show_config, start_service, stop_service, uninstall_service
def main(argv: Sequence[str] | None = None) -> int:
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 local MCP client configuration."
)
new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
new_token_parser.add_argument("--host", default=DEFAULT_HOST)
new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
new_token_parser.add_argument("--path", default=DEFAULT_PATH)
new_token_parser.add_argument("--nb-env", default=DEFAULT_NB_ENV)
new_token_parser.add_argument("--config-path", default=DEFAULT_CONFIG_PATH)
new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
start_parser = subparsers.add_parser(
"start", help="Install or update the user service using the saved token, then start it."
)
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
uninstall_parser = subparsers.add_parser(
"uninstall", help="Stop and remove the managed user service and token file."
)
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
show_config_parser = subparsers.add_parser(
"show-config", help="Print the saved token and local MCP client configuration."
)
show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
status_parser = subparsers.add_parser("status", help="Show systemd status for the managed service.")
status_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
args = parser.parse_args(argv)
if args.command == "new-token":
print(
new_token(
project_root=args.project_root,
host=args.host,
port=args.port,
path=args.path,
nb_env=args.nb_env,
config_path=args.config_path,
token_bytes=args.token_bytes,
),
end="",
)
return 0
if args.command == "start":
print(start_service(project_root=args.project_root), end="")
return 0
if args.command == "stop":
stop_service()
print(f"Stopped {SERVICE_NAME}.")
return 0
if args.command == "uninstall":
uninstall_service(args.project_root)
print(f"Uninstalled {SERVICE_NAME}.")
return 0
if args.command == "show-config":
print(show_config(args.project_root), end="")
return 0
if args.command == "status":
print(service_status(project_root=args.project_root), end="")
return 0
raise AssertionError(f"unhandled command: {args.command}")
__all__ = ["main"]
-264
View File
@@ -1,264 +0,0 @@
from __future__ import annotations
import os
import shlex
import shutil
from dataclasses import dataclass
from pathlib import Path
SERVICE_NAME = "nocobase-crm-mcp.service"
ENV_RELATIVE_PATH = Path(".runtime") / "nocobase-crm-mcp.env"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 10087
DEFAULT_PATH = "/mcp"
DEFAULT_NB_ENV = "nb-mcp"
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
ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS"
API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL"
API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN"
LEGACY_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL"
LEGACY_API_TOKEN_ENV = "NOCOBASE_MCP_UPLOAD_JWT"
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",
ENABLE_UPLOADS_ENV,
"NOCOBASE_MCP_UPLOAD_MAX_BYTES",
}
)
@dataclass(frozen=True)
class ServicePaths:
project_root: Path
unit_path: Path
env_path: Path
@dataclass(frozen=True)
class ServiceConfig:
token: str
host: str = DEFAULT_HOST
port: int = DEFAULT_PORT
path: str = DEFAULT_PATH
nb_env: str = DEFAULT_NB_ENV
config_path: str = DEFAULT_CONFIG_PATH
nb_bin: str = DEFAULT_NB_BIN
def systemd_quote(value: str | Path) -> str:
return shlex.quote(str(value))
def find_uv_path() -> Path:
configured = os.environ.get("UV_PATH", "").strip()
discovered = configured or shutil.which("uv")
if not discovered:
raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute path.")
return Path(discovered).expanduser().resolve()
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths:
root = project_root.expanduser().resolve()
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME
return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH)
def render_service_unit(
*,
project_root: Path,
env_file: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
uv_path: Path | None = None,
) -> str:
resolved_uv_path = uv_path or find_uv_path()
command = " ".join(
[
systemd_quote(resolved_uv_path),
"run",
"--no-sync",
"nocobase-crm-mcp",
"--transport",
"streamable-http",
"--host",
host,
"--port",
str(port),
"--path",
path,
]
)
return (
"[Unit]\n"
"Description=NocoBase CRM HTTP MCP server\n"
"After=network-online.target\n"
"Wants=network-online.target\n"
"\n"
"[Service]\n"
"Type=simple\n"
f"WorkingDirectory={systemd_quote(project_root)}\n"
f"EnvironmentFile={systemd_quote(env_file)}\n"
f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n"
f"ExecStart={command}\n"
"Restart=on-failure\n"
"RestartSec=5\n"
"\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def write_token_env_file(
env_path: Path,
token: str,
*,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH,
nb_bin: str | Path = DEFAULT_NB_BIN,
) -> None:
uploads_enabled = _saved_uploads_setting(env_path)
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)
with os.fdopen(fd, "w", encoding="utf-8") as env_file:
env_file.write(f"NOCOBASE_MCP_TOKEN={token}\n")
env_file.write(f"NOCOBASE_MCP_HOST={host}\n")
env_file.write(f"NOCOBASE_MCP_PORT={port}\n")
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_NB_BIN={nb_bin}\n")
env_file.write(f"{ENABLE_UPLOADS_ENV}={uploads_enabled}\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 _saved_uploads_setting(env_path: Path) -> str:
if not env_path.exists():
return "0"
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
key, separator, value = raw_line.strip().partition("=")
if separator and key.strip() == ENABLE_UPLOADS_ENV:
return "1" if value.strip() == "1" else "0"
return "0"
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(
f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it, "
"or scripts/mcp_service.sh new-token to rotate it."
)
values: dict[str, str] = {}
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
key, separator, value = line.partition("=")
if separator:
values[key.strip()] = value.strip()
return values
def saved_service_config(project_root: Path) -> ServiceConfig:
paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path)
token = values.get("NOCOBASE_MCP_TOKEN", "").strip()
if not token:
raise RuntimeError(f"Missing NOCOBASE_MCP_TOKEN in {paths.env_path}")
host = values.get("NOCOBASE_MCP_HOST") or DEFAULT_HOST
port = int(values.get("NOCOBASE_MCP_PORT") or DEFAULT_PORT)
path = values.get("NOCOBASE_MCP_PATH") or DEFAULT_PATH
nb_env = values.get("NOCOBASE_MCP_ENV") or DEFAULT_NB_ENV
config_path = values.get("NOCOBASE_MCP_CONFIG") or DEFAULT_CONFIG_PATH
nb_bin = values.get("NOCOBASE_MCP_NB_BIN") or DEFAULT_NB_BIN
return ServiceConfig(
token=token,
host=host,
port=port,
path=path,
nb_env=nb_env,
config_path=config_path,
nb_bin=nb_bin,
)
__all__ = [
"API_BASE_URL_ENV",
"API_TOKEN_ENV",
"DEFAULT_CONFIG_PATH",
"DEFAULT_HOST",
"DEFAULT_NB_BIN",
"DEFAULT_NB_ENV",
"DEFAULT_PATH",
"DEFAULT_PORT",
"DEFAULT_TOKEN_BYTES",
"DEFAULT_UPLOAD_MAX_BYTES",
"DEFAULT_UV_CACHE_DIR",
"ENABLE_UPLOADS_ENV",
"ENV_RELATIVE_PATH",
"LEGACY_API_BASE_URL_ENV",
"LEGACY_API_TOKEN_ENV",
"MANAGED_ENV_NAMES",
"SERVICE_NAME",
"ServiceConfig",
"ServicePaths",
"_operator_env_lines",
"_saved_uploads_setting",
"find_uv_path",
"read_service_env_file",
"render_service_unit",
"resolve_service_paths",
"saved_service_config",
"systemd_quote",
"write_token_env_file",
]
-354
View File
@@ -1,354 +0,0 @@
from __future__ import annotations
import os
import secrets
import shutil
import socket
import subprocess
import time
import urllib.error
import urllib.request
from collections.abc import Sequence
from pathlib import Path
from mcp_service_config import (
API_BASE_URL_ENV,
API_TOKEN_ENV,
DEFAULT_CONFIG_PATH,
DEFAULT_HOST,
DEFAULT_NB_ENV,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_TOKEN_BYTES,
ENABLE_UPLOADS_ENV,
LEGACY_API_BASE_URL_ENV,
LEGACY_API_TOKEN_ENV,
SERVICE_NAME,
ServicePaths,
find_uv_path,
read_service_env_file,
render_service_unit,
resolve_service_paths,
saved_service_config,
write_token_env_file,
)
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
return secrets.token_urlsafe(num_bytes)
def find_nb_path() -> Path:
configured = os.environ.get("NOCOBASE_MCP_NB_BIN", "").strip()
candidate = configured or "nb"
candidate_path = Path(candidate).expanduser()
if candidate_path.is_absolute():
if candidate_path.is_file() and os.access(candidate_path, os.X_OK):
return Path(os.path.abspath(candidate_path))
raise RuntimeError(f"NOCOBASE_MCP_NB_BIN points to {candidate_path}, but it is not an executable file.")
discovered = shutil.which(candidate)
if not discovered:
raise RuntimeError(
"Could not find nb on PATH. Install the NocoBase CLI or set NOCOBASE_MCP_NB_BIN to its absolute path."
)
return Path(os.path.abspath(str(Path(discovered).expanduser())))
def write_service_unit(
paths: ServicePaths, *, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH
) -> None:
paths.unit_path.parent.mkdir(parents=True, exist_ok=True)
unit = render_service_unit(
project_root=paths.project_root,
env_file=paths.env_path,
host=host,
port=port,
path=path,
uv_path=find_uv_path(),
)
paths.unit_path.write_text(unit, encoding="utf-8")
def detect_remote_hosts() -> list[str]:
hosts: list[str] = []
try:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False)
except OSError:
result = None
if result and result.returncode == 0:
hosts.extend(result.stdout.split())
try:
for info in socket.getaddrinfo(socket.gethostname(), None):
hosts.append(info[4][0])
except OSError:
pass
cleaned: list[str] = []
for host in hosts:
normalized = host.strip()
if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"):
continue
if normalized not in cleaned:
cleaned.append(normalized)
return cleaned
def render_remote_config(
*,
hosts: Sequence[str],
token: str,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
service_status: str | None = None,
) -> str:
host = hosts[0] if hosts else "127.0.0.1"
url = f"http://{host}:{port}{path}"
alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:])
alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else ""
status_section = f"Service status: {service_status}\n\n" if service_status else ""
return (
"Saved MCP client config.\n"
"\n"
f"{status_section}"
"Use this loopback URL on this machine:\n"
f"{url}\n"
f"{alternatives_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
"\n"
"Example MCP client config:\n"
"{\n"
' "mcpServers": {\n'
' "nocobase-crm": {\n'
' "type": "streamable-http",\n'
f' "url": "{url}",\n'
' "headers": {\n'
f' "Authorization": "Bearer {token}"\n'
" }\n"
" }\n"
" }\n"
"}\n"
)
def show_config(project_root: Path) -> str:
config = saved_service_config(project_root)
display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host
return render_remote_config(
hosts=[display_host],
token=config.token,
port=config.port,
path=config.path,
service_status=service_state(),
)
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
def service_state() -> str:
result = subprocess.run(
["systemctl", "--user", "is-active", SERVICE_NAME],
capture_output=True,
text=True,
check=False,
)
state = result.stdout.strip()
if state:
return state
if result.returncode == 0:
return "active"
return "unknown"
def probe_http_service(host: str, port: int, path: str, *, token: str, timeout: float = 2.0) -> dict[str, object]:
url = f"http://{host}:{port}{path}"
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return {"reachable": True, "status": response.status, "url": url}
except urllib.error.HTTPError as error:
return {"reachable": True, "status": error.code, "url": url}
except OSError as error:
return {"reachable": False, "error": str(error), "url": url}
def wait_for_http_service(
host: str,
port: int,
path: str,
*,
token: str,
attempts: int = 20,
interval_seconds: float = 0.25,
) -> dict[str, object]:
last_probe: dict[str, object] = {"reachable": False, "error": "not probed"}
for attempt in range(max(1, attempts)):
last_probe = probe_http_service(host, port, path, token=token)
if last_probe.get("reachable"):
return last_probe
if attempt < attempts - 1:
time.sleep(interval_seconds)
return last_probe
def restart_service_if_installed(paths: ServicePaths) -> bool:
if not paths.unit_path.exists():
return False
run_systemctl(["restart", SERVICE_NAME])
return True
def new_token(
*,
project_root: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH,
token_bytes: int = DEFAULT_TOKEN_BYTES,
) -> str:
paths = resolve_service_paths(project_root)
token = generate_token(token_bytes)
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
nb_env=nb_env,
config_path=config_path,
nb_bin=find_nb_path(),
)
restart_service_if_installed(paths)
display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
return render_remote_config(hosts=[display_host], token=token, port=port, path=path, service_status=service_state())
def start_service(*, project_root: Path) -> str:
paths = resolve_service_paths(project_root)
nb_bin = find_nb_path()
if not paths.env_path.exists():
write_token_env_file(
paths.env_path,
generate_token(),
host=DEFAULT_HOST,
port=DEFAULT_PORT,
path=DEFAULT_PATH,
nb_env=DEFAULT_NB_ENV,
config_path=DEFAULT_CONFIG_PATH,
nb_bin=nb_bin,
)
config = saved_service_config(project_root)
write_token_env_file(
paths.env_path,
config.token,
host=config.host,
port=config.port,
path=config.path,
nb_env=config.nb_env,
config_path=config.config_path,
nb_bin=nb_bin,
)
write_service_unit(paths, host=config.host, port=config.port, path=config.path)
run_systemctl(["daemon-reload"])
run_systemctl(["enable", SERVICE_NAME])
run_systemctl(["restart", SERVICE_NAME])
try:
run_systemctl(["is-active", "--quiet", SERVICE_NAME])
except subprocess.CalledProcessError as error:
raise RuntimeError(f"{SERVICE_NAME} did not become active after restart") from error
probe = wait_for_http_service("127.0.0.1", config.port, config.path, token=config.token)
if not probe.get("reachable"):
raise RuntimeError(f"{SERVICE_NAME} is active but HTTP probe failed: {probe.get('error')}")
display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host
return render_remote_config(
hosts=[display_host],
token=config.token,
port=config.port,
path=config.path,
service_status="active",
)
def stop_service() -> None:
run_systemctl(["stop", SERVICE_NAME])
def uninstall_service(project_root: Path) -> None:
paths = resolve_service_paths(project_root)
run_systemctl(["disable", "--now", SERVICE_NAME], check=False)
if paths.unit_path.exists():
paths.unit_path.unlink()
if paths.env_path.exists():
paths.env_path.unlink()
try:
paths.env_path.parent.rmdir()
except OSError:
pass
run_systemctl(["daemon-reload"], check=False)
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
def service_status(*, project_root: Path) -> str:
paths = resolve_service_paths(project_root)
state = service_state()
lines = [
f"Service status: {state}",
f"Unit file: {paths.unit_path}",
f"Env file: {paths.env_path}",
]
if paths.env_path.exists():
values = read_service_env_file(paths.env_path)
config = saved_service_config(project_root)
display_host = "127.0.0.1" if config.host in {"0.0.0.0", "::"} else config.host
lines.append(f"MCP URL: http://{display_host}:{config.port}{config.path}")
lines.append(f"nb binary: {config.nb_bin}")
lines.append(f"NocoBase CLI env: {config.nb_env} (personal API Key managed by the Agent installer)")
uploads_enabled = values.get(ENABLE_UPLOADS_ENV) == "1"
lines.append(f"Attachment uploads: {'enabled' if uploads_enabled else 'disabled (safe default)'}")
canonical_configured = bool(values.get(API_BASE_URL_ENV) and values.get(API_TOKEN_ENV))
legacy_configured = bool(values.get(LEGACY_API_BASE_URL_ENV) and values.get(LEGACY_API_TOKEN_ENV))
if canonical_configured:
credential_state = "configured (canonical)"
elif legacy_configured:
credential_state = "configured (legacy)"
elif any(
values.get(name)
for name in (API_BASE_URL_ENV, API_TOKEN_ENV, LEGACY_API_BASE_URL_ENV, LEGACY_API_TOKEN_ENV)
):
credential_state = "incomplete"
else:
credential_state = "not configured; using the local nb CLI login"
lines.append(f"Legacy NocoBase credential override: {credential_state}")
probe = probe_http_service("127.0.0.1", config.port, config.path, token=config.token)
if probe.get("reachable"):
lines.append(f"HTTP probe: reachable (status {probe.get('status')})")
else:
lines.append(f"HTTP probe: not reachable ({probe.get('error')})")
else:
lines.append("MCP URL: unavailable until first start creates the env file")
lines.append("HTTP probe: skipped")
return "\n".join(lines) + "\n"
__all__ = [
"generate_token",
"find_nb_path",
"write_service_unit",
"detect_remote_hosts",
"render_remote_config",
"show_config",
"run_systemctl",
"service_state",
"probe_http_service",
"wait_for_http_service",
"restart_service_if_installed",
"new_token",
"start_service",
"stop_service",
"uninstall_service",
"service_status",
]
+12
View File
@@ -0,0 +1,12 @@
import { readFile } from "node:fs/promises";
const tag = process.argv[2];
if (!tag?.startsWith("v")) throw new Error("Release tag must have the form v<version>");
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
const manifest = JSON.parse(await readFile(new URL("../install-manifest.json", import.meta.url), "utf8"));
const expected = tag.slice(1);
if (packageJson.version !== expected || manifest.version !== expected) {
throw new Error(
`Version mismatch: tag=${tag}, package=${packageJson.version}, manifest=${manifest.version}`,
);
}
+196
View File
@@ -0,0 +1,196 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadConfig } from "./config.js";
import {
credentialsPath,
loadCredentials,
normalizeApiBaseUrl,
readHidden,
saveCredentials,
} from "./credentials.js";
import { CrmService } from "./crm-service.js";
import { NocoBaseClient } from "./nocobase-client.js";
import { ProjectService } from "./project-service.js";
import { createServer } from "./tools.js";
import { VERSION } from "./version.js";
interface ParsedArgs {
command: "serve" | "setup" | "doctor" | "client-config" | "help" | "version";
apiBaseUrl?: string;
config?: string;
enableWrites: boolean;
json: boolean;
}
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
const args = parseArgs(argv);
if (args.command === "help") {
process.stdout.write(helpText());
return;
}
if (args.command === "version") {
process.stdout.write(`${VERSION}\n`);
return;
}
if (args.command === "setup") {
await setup(args);
return;
}
const configPath = resolveConfigPath(args.config);
if (args.command === "client-config") {
printResult(clientConfig(configPath, args.enableWrites), args.json);
return;
}
const runtime = createRuntime(configPath);
if (args.command === "doctor") {
await runtime.client.verifyCredentials();
const health = await runtime.crm.crmHealth();
printResult({ ok: true, credentials: "verified", health }, args.json);
return;
}
const server = createServer({
crmService: runtime.crm,
projectService: runtime.projects,
readonly: !args.enableWrites,
enableConfigTools: process.env.NOCOBASE_MCP_ENABLE_CONFIG_TOOLS?.trim() === "1",
});
const close = async (): Promise<void> => {
await server.close();
process.exit(0);
};
process.once("SIGINT", close);
process.once("SIGTERM", close);
await server.connect(new StdioServerTransport());
}
export function createRuntime(configPath: string): {
client: NocoBaseClient;
crm: CrmService;
projects: ProjectService;
} {
const config = loadConfig(configPath);
const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource });
return {
client,
crm: new CrmService(config, client),
projects: new ProjectService(config.project, client),
};
}
async function setup(args: ParsedArgs): Promise<void> {
if (!args.apiBaseUrl) throw new Error("setup requires --api-base-url <https://nocobase.example>");
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
}
const apiBaseUrl = normalizeApiBaseUrl(args.apiBaseUrl);
process.stderr.write(
"Create a revocable personal Authorize Key in NocoBase for your own business account.\n" +
"Do not paste the Key into Agent chat. Enter it only in the hidden prompt below.\n",
);
const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl, authorizeKey }));
await verificationClient.verifyCredentials();
const configPath = ensureConfig(args.config);
saveCredentials({ apiBaseUrl, authorizeKey });
const runtime = createRuntime(configPath);
const health = await runtime.crm.crmHealth();
printResult(
{
ok: true,
apiBaseUrl,
credentialsPath: credentialsPath(),
keyExposure: "private-tty-and-0600-local-file-only",
configPath,
mode: args.enableWrites ? "fixed-project-writes" : "read-only",
clientConfig: clientConfig(configPath, args.enableWrites),
health,
},
args.json,
);
}
function ensureConfig(explicit?: string): string {
if (explicit) {
const path = resolve(explicit);
if (!existsSync(path)) throw new Error(`Config file does not exist: ${path}`);
return path;
}
const destination = join(dirname(credentialsPath()), "config.json");
if (existsSync(destination)) return destination;
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const template = join(packageRoot, "config.example.json");
if (!existsSync(template)) throw new Error(`Bundled config template is missing: ${template}`);
copyFileSync(template, destination, 0);
return destination;
}
function resolveConfigPath(explicit?: string): string {
const path = explicit ?? process.env.NOCOBASE_MCP_CONFIG ?? join(dirname(credentialsPath()), "config.json");
const resolved = resolve(path);
if (!existsSync(resolved)) throw new Error(`CRM config is missing: ${resolved}. Run setup first.`);
return resolved;
}
export function clientConfig(configPath: string, enableWrites = false): Record<string, unknown> {
const entrypoint = resolve(process.argv[1] ?? fileURLToPath(import.meta.url));
return {
mcpServers: {
"nocobase-crm": {
command: process.execPath,
args: [entrypoint, "--config", configPath, ...(enableWrites ? ["--enable-writes"] : [])],
},
},
};
}
function parseArgs(argv: string[]): ParsedArgs {
const first = argv[0];
let command: ParsedArgs["command"] = "serve";
let index = 0;
if (first && !first.startsWith("-")) {
if (!["serve", "setup", "doctor", "client-config", "help", "version"].includes(first)) {
throw new Error(`Unknown command: ${first}`);
}
command = first as ParsedArgs["command"];
index = 1;
}
const parsed: ParsedArgs = { command, enableWrites: false, json: false };
while (index < argv.length) {
const flag = argv[index];
if (flag === "--help" || flag === "-h") parsed.command = "help";
else if (flag === "--version" || flag === "-v") parsed.command = "version";
else if (flag === "--enable-writes") parsed.enableWrites = true;
else if (flag === "--json") parsed.json = true;
else if (flag === "--api-base-url" || flag === "--config") {
const value = argv[index + 1];
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
if (flag === "--api-base-url") parsed.apiBaseUrl = value;
else parsed.config = value;
index += 1;
} else throw new Error(`Unknown option: ${flag}`);
index += 1;
}
return parsed;
}
function printResult(value: unknown, json: boolean): void {
process.stdout.write(`${JSON.stringify(value, null, json ? 0 : 2)}\n`);
}
function helpText(): string {
return `NocoBase CRM MCP ${VERSION}
Usage:
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
nocobase-crm-mcp doctor [--config <path>] [--json]
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
The setup command accepts the personal Authorize Key only through a hidden local TTY prompt.
The default stdio server is read-only.
`;
}
+299
View File
@@ -0,0 +1,299 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { isRecord } from "./types.js";
export const PROJECT_PROGRESS_VALUES: Record<string, string> = {
询价阶段: "fk9xj8akn46",
已报价: "k4w2vcz819w",
送样: "g95vqeetu83",
已定点: "fe0peg505d7",
SOP: "a9t9awhtt3r",
终止: "a9t9awhtsfasg3r",
};
export interface CustomerConfig {
resource: string;
idField: string;
fields: string[] | null;
searchFields: string[] | null;
nameFields: string[] | null;
englishNameField: string;
territorialNameField: string;
statusField: string;
sourceField: string;
ownerField: string;
managerField: string;
detailFields: Record<string, string>;
sort: string[] | null;
}
export interface ContactConfig {
resource: string;
idField: string;
fields: string[] | null;
searchFields: string[] | null;
nameFields: string[] | null;
titleField: string;
phoneField: string;
emailField: string;
customerField: string;
sort: string[] | null;
}
export interface FollowupConfig {
resource: string;
idField: string;
fields: string[] | null;
searchFields: string[] | null;
customerField: string;
contactField: string;
ownerField: string;
ownerFields: string[] | null;
dateField: string;
contentField: string;
typeField: string;
statusField: string;
projectField: string;
locationField: string;
sort: string[] | null;
}
export interface UserConfig {
resource: string;
idField: string;
fields: string[] | null;
matchFields: string[] | null;
labelFields: string[] | null;
}
export interface TeamConfig {
resource: string;
idField: string;
nameField: string;
managerField: string;
memberField: string;
userStatusField: string;
formalUserStatusValue: string;
}
export interface ProjectConfig {
resource: string;
idField: string;
nameField: string;
nameFields: string[] | null;
customerField: string;
customerRelationField: string;
inquiryDateField: string;
progressField: string;
applicationsField: string;
partNumbersField: string;
periodUsageField: string;
detailsField: string;
attachmentField: string;
contactField: string;
followupField: string;
updateField: string;
updateResource: string;
updateProjectField: string;
updateDateField: string;
updateContentField: string;
updateAttachmentField: string;
progressValues: Record<string, string>;
}
export interface CrmConfig {
dataSource: string;
timezone: string;
customer: CustomerConfig;
contact: ContactConfig;
followup: FollowupConfig;
user: UserConfig;
team: TeamConfig;
project: ProjectConfig;
}
export const DEFAULT_CONFIG: CrmConfig = {
dataSource: "main",
timezone: "Asia/Shanghai",
customer: {
resource: "customers",
idField: "id",
fields: ["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"],
searchFields: ["name", "code"],
nameFields: ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name", "name", "title", "code"],
englishNameField: "f_kh_english_name",
territorialNameField: "f_territorial_name",
statusField: "status",
sourceField: "source",
ownerField: "owner",
managerField: "kehu_manage_person",
detailFields: {
address: "f_y8iyf2jhana",
intro: "f_spgoie0arjq",
tags: "kh_tags",
followup_interval_days: "f_kyf51uh0ky7",
},
sort: ["-updatedAt"],
},
contact: {
resource: "contacts",
idField: "id",
fields: ["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"],
searchFields: ["name", "phone", "email"],
nameFields: ["f_2zpg91gtmwp", "name", "title"],
titleField: "f_tn8zdm2f938",
phoneField: "khlxr_phone_number",
emailField: "khlxr_email",
customerField: "customer",
sort: ["-updatedAt"],
},
followup: {
resource: "followups",
idField: "id",
fields: [
"id",
"customer",
"contact",
"owner",
"content",
"followedAt",
"nextFollowAt",
"createdAt",
"updatedAt",
],
searchFields: ["content"],
customerField: "customer",
contactField: "contact",
ownerField: "owner",
ownerFields: ["owner"],
dateField: "followedAt",
contentField: "f_19iplxz0nds",
typeField: "gjjl_followup_type",
statusField: "f_hskl7gjvos9",
projectField: "f_gjjl_projects",
locationField: "f_b5c4hfi5og6",
sort: ["-followedAt", "-createdAt"],
},
user: {
resource: "users",
idField: "id",
fields: ["id", "username", "nickname", "email", "f_yh_english_name"],
matchFields: ["username", "nickname", "email", "f_yh_english_name"],
labelFields: ["nickname", "f_yh_english_name", "username", "email"],
},
team: {
resource: "t_team_management",
idField: "id",
nameField: "f_team_name",
managerField: "tdgl_manager",
memberField: "tdgl_team_members",
userStatusField: "yh_status",
formalUserStatusValue: "nkluuumb1r1",
},
project: {
resource: "t_projects",
idField: "id",
nameField: "f_project_name",
nameFields: ["f_project_name", "name", "title"],
customerField: "client_ID",
customerRelationField: "f_projects_client",
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",
contactField: "f_projects_contacts",
followupField: "f_projects_followup_records",
updateField: "f_u28gm9h2uzl",
updateResource: "t_qxeepjvi39s",
updateProjectField: "f_k37rbpi4vye",
updateDateField: "f_6g9fw5oqef9",
updateContentField: "f_ei16g0ihwwd",
updateAttachmentField: "f_6pk5pv17i27",
progressValues: { ...PROJECT_PROGRESS_VALUES },
},
};
function cloneDefaults(): CrmConfig {
return structuredClone(DEFAULT_CONFIG);
}
function assignKnown(target: Record<string, unknown>, source: Record<string, unknown>): void {
for (const [key, value] of Object.entries(source)) {
if (!(key in target) || value === undefined || value === null || value === "") continue;
const current = target[key];
if (Array.isArray(current)) {
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
throw new Error(`CRM config ${key} must be an array of strings`);
}
target[key] = [...value];
continue;
}
if (typeof current === "string") {
if (typeof value !== "string") throw new Error(`CRM config ${key} must be a string`);
target[key] = value;
continue;
}
if (isRecord(current)) {
if (!isRecord(value)) throw new Error(`CRM config ${key} must be an object`);
const stringMap = Object.values(current).every((item) => typeof item === "string");
if (stringMap) {
if (!Object.values(value).every((item) => typeof item === "string")) {
throw new Error(`CRM config ${key} must map strings to strings`);
}
target[key] = { ...value };
} else assignKnown(current, value);
continue;
}
throw new Error(`CRM config ${key} has an unsupported value`);
}
}
function normalizeSection(raw: unknown): Record<string, unknown> {
if (!isRecord(raw)) return {};
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
normalized[key.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase())] = value;
}
return normalized;
}
export function loadConfig(path = process.env.NOCOBASE_MCP_CONFIG): CrmConfig {
const config = cloneDefaults();
if (path) {
const parsed: unknown = JSON.parse(readFileSync(resolve(path), "utf8"));
if (!isRecord(parsed)) throw new Error("CRM config must be a JSON object");
const collections = isRecord(parsed.collections) ? parsed.collections : {};
assignKnown(
config.customer as unknown as Record<string, unknown>,
normalizeSection(collections.customers),
);
assignKnown(config.contact as unknown as Record<string, unknown>, normalizeSection(collections.contacts));
assignKnown(
config.followup as unknown as Record<string, unknown>,
normalizeSection(collections.followups),
);
assignKnown(
config.user as unknown as Record<string, unknown>,
normalizeSection(parsed.users ?? parsed.user),
);
assignKnown(
config.team as unknown as Record<string, unknown>,
normalizeSection(parsed.teams ?? parsed.team),
);
assignKnown(config.project as unknown as Record<string, unknown>, normalizeSection(parsed.projects));
const dataSource = parsed.dataSource ?? parsed.data_source;
if (typeof dataSource === "string" && dataSource.trim()) config.dataSource = dataSource.trim();
if (typeof parsed.timezone === "string" && parsed.timezone.trim())
config.timezone = parsed.timezone.trim();
}
if (process.env.NOCOBASE_MCP_DATA_SOURCE?.trim())
config.dataSource = process.env.NOCOBASE_MCP_DATA_SOURCE.trim();
if (process.env.NOCOBASE_MCP_TIMEZONE?.trim()) config.timezone = process.env.NOCOBASE_MCP_TIMEZONE.trim();
if (!config.followup.ownerFields)
config.followup.ownerFields = config.followup.ownerField ? [config.followup.ownerField] : [];
if (!config.project.nameFields) config.project.nameFields = [config.project.nameField, "name", "title"];
return config;
}
+114
View File
@@ -0,0 +1,114 @@
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { isRecord } from "./types.js";
export interface Credentials {
apiBaseUrl: string;
authorizeKey: string;
}
export function normalizeApiBaseUrl(value: string): string {
const raw = value.trim();
if (!raw) throw new Error("NocoBase URL is required");
const url = new URL(raw);
if (!(["http:", "https:"] as string[]).includes(url.protocol))
throw new Error("NocoBase URL must use HTTP or HTTPS");
if (url.username || url.password || url.search || url.hash) {
throw new Error("NocoBase URL must not contain credentials, a query string, or a fragment");
}
const local = url.hostname === "localhost" || url.hostname === "::1" || url.hostname.startsWith("127.");
if (url.protocol !== "https:" && !local)
throw new Error("A remote Authorize Key may only be sent over HTTPS");
url.pathname = `${url.pathname.replace(/\/+$/, "")}${url.pathname.replace(/\/+$/, "").endsWith("/api") ? "" : "/api"}`;
return url.toString().replace(/\/$/, "");
}
export function credentialsPath(): string {
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
if (override) return resolve(override);
if (process.platform === "win32") {
const base = process.env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming");
return join(base, "nocobase-crm-mcp", "credentials.json");
}
if (process.platform === "darwin") {
return join(homedir(), "Library", "Application Support", "nocobase-crm-mcp", "credentials.json");
}
const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
return join(base, "nocobase-crm-mcp", "credentials.json");
}
export function saveCredentials(credentials: Credentials, path = credentialsPath()): void {
const normalized: Credentials = {
apiBaseUrl: normalizeApiBaseUrl(credentials.apiBaseUrl),
authorizeKey: credentials.authorizeKey.trim(),
};
if (!normalized.authorizeKey) throw new Error("Authorize Key must not be blank");
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
if (process.platform !== "win32") {
try {
const directoryMode = statSync(dirname(path)).mode & 0o777;
if ((directoryMode & 0o077) !== 0)
throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`);
} catch (error) {
if (existsSync(dirname(path))) throw error;
}
}
const descriptor = openSync(path, "w", 0o600);
try {
writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
} finally {
closeSync(descriptor);
}
}
export function loadCredentials(path = credentialsPath()): Credentials {
if (process.platform !== "win32") {
const mode = statSync(path).mode & 0o777;
if ((mode & 0o077) !== 0)
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
}
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed) || typeof parsed.apiBaseUrl !== "string" || typeof parsed.authorizeKey !== "string") {
throw new Error("Credential file is invalid; run setup again");
}
const authorizeKey = parsed.authorizeKey.trim();
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again");
return { apiBaseUrl: normalizeApiBaseUrl(parsed.apiBaseUrl), authorizeKey };
}
export async function readHidden(prompt: string): Promise<string> {
if (!process.stdin.isTTY || !process.stderr.isTTY || typeof process.stdin.setRawMode !== "function") {
throw new Error("Secure Authorize Key entry requires a user-controlled local terminal");
}
process.stderr.write(prompt);
process.stdin.setEncoding("utf8");
process.stdin.setRawMode(true);
process.stdin.resume();
return await new Promise<string>((resolveValue, reject) => {
let value = "";
const cleanup = (): void => {
process.stdin.off("data", onData);
process.stdin.setRawMode(false);
process.stdin.pause();
process.stderr.write("\n");
};
const onData = (chunk: string): void => {
for (const character of chunk) {
if (character === "\u0003") {
cleanup();
reject(new Error("Authorize Key entry cancelled"));
return;
}
if (character === "\r" || character === "\n") {
cleanup();
resolveValue(value);
return;
}
if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
else if (character >= " ") value += character;
}
};
process.stdin.on("data", onData);
});
}
+324
View File
@@ -0,0 +1,324 @@
import type { ApiResponse, RecordRow } from "./types.js";
import { isRecord } from "./types.js";
export const MAX_PAGE_SIZE = 100;
export const MAX_SCAN_RECORDS = 5_000;
export const PROJECTION_FALLBACK_WARNING =
"NocoBase rejected relation field projection; check nameFields in the CRM config";
export const CUSTOMER_STATUS_LABELS: Record<string, string> = {
kh_p_not_start: "未开始",
kh_p_inprogress: "进行中",
kh_p_ordered: "已下单",
kh_p_audited: "已审核",
};
export const FOLLOWUP_CONTENT_KEYS = ["f_19iplxz0nds", "content", "description", "remark"];
export const FOLLOWUP_DATE_KEYS = ["f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt"];
export const FOLLOWUP_TYPE_LABELS: Record<string, string> = {
tdwfk5h1ohv: "邮件",
s48pnyhutwc: "电话",
mwyy8xwk66m: "视频会议",
m34aocz3ebo: "拜访/请客",
"0lmj78zpdnm": "审厂/来访",
o7rjlg4guzh: "论坛/大会",
"2csix8r3h0f": "代理跟进情况代填",
};
export const FOLLOWUP_STATUS_LABELS: Record<string, string> = {
"2rowk1bc8vc": "审批中",
"3ge6geal2u1": "OK",
"68vqz8raus6": "迟交",
b5pf5njqgp6: "无效",
};
export const INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6";
export interface LabelSpec {
idField: string;
nameFields: string[];
}
export function normalizePagination(limit: unknown, offset: unknown): [number, number] {
return [Math.min(Math.max(1, integer(limit, 20)), MAX_PAGE_SIZE), Math.max(0, integer(offset, 0))];
}
export function integer(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
export function dataRows(response: ApiResponse): RecordRow[] {
if (Array.isArray(response.data)) return response.data.filter(isRecord);
if (isRecord(response.data)) {
for (const key of ["data", "rows", "results"]) {
const nested = response.data[key];
if (Array.isArray(nested)) return nested.filter(isRecord);
}
}
return [];
}
export function singleRow(response: ApiResponse): RecordRow | undefined {
if (Array.isArray(response.data)) return response.data.find(isRecord);
return isRecord(response.data) ? response.data : undefined;
}
export function responseTotal(response: ApiResponse, fallback: number): number {
for (const container of [response.meta, response.data]) {
if (!isRecord(container)) continue;
for (const key of ["count", "total", "totalCount"]) {
const value = container[key];
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
}
}
return fallback;
}
export function paginatedRows(
rows: unknown[],
total: number,
limit: number,
offset: number,
): Record<string, unknown> {
const nextOffset = offset + rows.length;
return {
results: rows,
total,
returned: rows.length,
limit,
offset,
has_more: rows.length > 0 && nextOffset < total,
next_offset: nextOffset < total ? nextOffset : null,
};
}
export function paginationMetadata(result: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
["total", "returned", "limit", "offset", "has_more", "next_offset"].map((key) => [key, result[key]]),
);
}
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
}
export function compactFieldList(configured: string[] | null, candidates: string[]): string[] {
const available = new Set(configured ?? []);
return uniqueStrings(candidates).filter((candidate) => available.size === 0 || available.has(candidate));
}
export function fieldsWithProjections(scalars: string[], projections: string[]): string[] {
const roots = new Set(projections.map((path) => path.split(".", 1)[0]));
return [...uniqueStrings(scalars).filter((field) => !roots.has(field)), ...uniqueStrings(projections)];
}
export function firstValue(row: RecordRow, fields: string[]): unknown {
for (const field of uniqueStrings(fields)) {
const value = row[field];
if (value !== undefined && value !== null && value !== "") return value;
}
return undefined;
}
export function firstText(row: RecordRow, fields: string[]): string | undefined {
const value = firstValue(row, fields);
return typeof value === "object" ? undefined : value === undefined ? undefined : String(value);
}
export function firstNumeric(rows: RecordRow[], preferredKey: string): number {
for (const row of rows) {
const preferred = row[preferredKey];
if (typeof preferred === "number") return Math.trunc(preferred);
const nested = Object.values(row).find((value) => typeof value === "number");
if (typeof nested === "number") return Math.trunc(nested);
}
return 0;
}
export function prune(mapping: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(mapping).filter(([key, value]) => key === "id" || !isEmpty(value)),
);
}
export function relationRows(value: unknown): RecordRow[] {
if (Array.isArray(value)) return value.filter(isRecord);
if (isRecord(value)) {
if (Array.isArray(value.data)) return value.data.filter(isRecord);
return [value];
}
return [];
}
export function refFromRow(row: RecordRow, spec: LabelSpec): Record<string, unknown> {
const id = row[spec.idField];
const found = firstText(row, spec.nameFields);
return prune({ id, name: found ?? (id !== undefined && id !== null ? `#${String(id)}` : undefined) });
}
export function relationRefs(
row: RecordRow,
fields: string[],
spec: LabelSpec,
): Array<Record<string, unknown>> {
const result: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const field of uniqueStrings(fields)) {
for (const related of relationRows(row[field])) {
const ref = refFromRow(related, spec);
const key = JSON.stringify(ref.id ?? ref.name);
if (Object.keys(ref).length === 0 || seen.has(key)) continue;
seen.add(key);
result.push(ref);
}
}
return result;
}
export function relationIds(value: unknown, idField = "id"): unknown[] {
return relationRows(value)
.map((row) => row[idField])
.filter((value) => value !== undefined && value !== null);
}
export function refIds(refs: Array<Record<string, unknown>>): unknown[] {
return refs.map((ref) => ref.id).filter((value) => value !== undefined && value !== null);
}
export function refNames(refs: Array<Record<string, unknown>>): string[] {
return refs
.map((ref) => ref.name)
.filter((value): value is string => typeof value === "string" && value.length > 0);
}
export function formatUserCandidate(row: RecordRow, idField: string): Record<string, unknown> {
const label = firstText(row, ["nickname", "username", "email", "f_yh_english_name", "id"]) ?? "<unnamed>";
return prune({
id: row[idField],
label,
username: row.username,
nickname: row.nickname,
email: row.email,
f_yh_english_name: row.f_yh_english_name,
});
}
export function pickUserMatches(rows: RecordRow[], query: string, matchFields: string[]): RecordRow[] {
const normalized = query.toLocaleLowerCase();
const exact = rows.filter((row) =>
matchFields.some(
(field) =>
String(row[field] ?? "")
.trim()
.toLocaleLowerCase() === normalized,
),
);
if (exact.length > 0) return exact;
return rows.filter((row) =>
matchFields.some((field) =>
String(row[field] ?? "")
.trim()
.toLocaleLowerCase()
.includes(normalized),
),
);
}
export function decorateDimensionCounts(
rows: RecordRow[],
preferredKeys: string[],
labels: Record<string, string>,
): RecordRow[] {
const decorated = rows.map((row) => {
const [value, key] = dimensionValue(row, preferredKeys);
const item = { ...row };
if (value !== undefined && value !== null) {
if (key && key !== "value" && key !== "label") delete item[key];
item.value = value;
item.label = labels[String(value)] ?? String(value);
}
return item;
});
return decorated.sort(
(left, right) =>
Number(isBlank(left.value)) - Number(isBlank(right.value)) ||
countValue(right) - countValue(left) ||
String(left.label ?? "").localeCompare(String(right.label ?? "")),
);
}
export function decorateIdCounts(rows: RecordRow[], preferredKeys: string[], outputKey: string): RecordRow[] {
return rows
.map((row) => {
const [value, key] = dimensionValue(row, preferredKeys);
const item = { ...row };
if (value !== undefined && value !== null) {
if (key && key !== outputKey) delete item[key];
item[outputKey] = value;
}
return item;
})
.sort(
(left, right) =>
countValue(right) - countValue(left) ||
String(left[outputKey] ?? "").localeCompare(String(right[outputKey] ?? "")),
);
}
export function toZonedIso(value: unknown, timezone: string): unknown {
if (typeof value !== "string" || !value.trim()) return value;
const normalized = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value;
try {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
const localUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
const offsetMinutes = Math.round((localUtc - date.getTime()) / 60_000);
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
const offset = `${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`;
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${offset}`;
} catch {
return date.toISOString();
}
}
export function parseDate(value: unknown): Date | undefined {
if (typeof value !== "string") return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
function dimensionValue(row: RecordRow, preferredKeys: string[]): [unknown, string | undefined] {
for (const key of preferredKeys) if (key in row) return [row[key], key];
const entry = Object.entries(row).find(([key, value]) => key !== "count" && typeof value !== "object");
return entry ? [entry[1], entry[0]] : [undefined, undefined];
}
function countValue(row: RecordRow): number {
return typeof row.count === "number" ? Math.trunc(row.count) : 0;
}
function isBlank(value: unknown): boolean {
return value === undefined || value === null || String(value).trim() === "";
}
function isEmpty(value: unknown): boolean {
return isBlank(value) || (typeof value === "object" && value !== null && Object.keys(value).length === 0);
}
+1477
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
export type Filter = Record<string, unknown>;
export function buildSearchFilter(
query: string | null | undefined,
fields: Iterable<string> | null | undefined,
): Filter | undefined {
const normalized = query?.trim() ?? "";
const names = [...(fields ?? [])].filter(Boolean);
if (!normalized || names.length === 0) return undefined;
return { $or: names.map((field) => ({ [field]: { $includes: normalized } })) };
}
export function buildEqCondition(field: string | null | undefined, value: unknown): Filter | undefined {
if (!field || value === undefined || value === null || value === "") return undefined;
return { [field]: { $eq: value } };
}
export function buildRelationIdCondition(
field: string | string[] | null | undefined,
value: unknown,
): Filter | undefined {
if (!field || value === undefined || value === null || value === "") return undefined;
const path = relationPath(field);
if (path.length === 0) return undefined;
return nested(path, { $eq: value });
}
export function buildRelationIdInCondition(
field: string | string[] | null | undefined,
values: unknown[] | null | undefined,
): Filter | undefined {
if (!field) return undefined;
const normalized = (values ?? []).filter((value) => value !== undefined && value !== null && value !== "");
const path = relationPath(field);
if (normalized.length === 0 || path.length === 0) return undefined;
return nested(path, { $in: normalized });
}
export function buildDateRangeCondition(
field: string | null | undefined,
dateFrom: string | null | undefined,
dateTo: string | null | undefined,
): Filter | undefined {
if (!field) return undefined;
const lower = dateFrom?.trim() ?? "";
const upper = dateTo?.trim() ?? "";
if (lower && upper) return { [field]: { $dateBetween: [lower, upper] } };
if (lower) return { [field]: { $dateNotBefore: lower } };
if (upper) return { [field]: { $dateNotAfter: upper } };
return undefined;
}
export function buildAndFilter(conditions: Array<Filter | null | undefined>): Filter {
const filtered = conditions.filter((condition): condition is Filter => Boolean(condition));
return filtered.length === 0 ? {} : { $and: filtered };
}
function relationPath(field: string | string[]): string[] {
const parts = (typeof field === "string" ? field.split(".") : field).map(String).filter(Boolean);
return parts.length === 1 ? [parts[0] as string, "id"] : parts;
}
function nested(path: string[], value: unknown): Filter {
let current: unknown = value;
for (const part of [...path].reverse()) current = { [part]: current };
return current as Filter;
}
+7
View File
@@ -0,0 +1,7 @@
import { runCli } from "./cli.js";
runCli().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`nocobase-crm-mcp: ${message}\n`);
process.exitCode = 1;
});
+366
View File
@@ -0,0 +1,366 @@
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import type { Credentials } from "./credentials.js";
import type { ApiResponse, RecordRow } from "./types.js";
import { isRecord } from "./types.js";
const AUTHENTICATION_CODES = [
"EMPTY_TOKEN",
"INVALID_TOKEN",
"EXPIRED_TOKEN",
"BLOCKED_TOKEN",
"EXPIRED_SESSION",
"NOT_EXIST_USER",
];
export class NocoBaseError extends Error {
readonly statusCode: number;
readonly authenticationFailed: boolean;
readonly retryable: boolean;
constructor(
statusCode: number,
message: string,
options: { authenticationFailed?: boolean; retryable?: boolean } = {},
) {
super(message);
this.name = "NocoBaseError";
this.statusCode = statusCode;
this.authenticationFailed = options.authenticationFailed ?? statusCode === 401;
this.retryable = options.retryable ?? (statusCode === 429 || statusCode >= 500);
}
}
export interface ListOptions {
filter?: Record<string, unknown>;
fields?: string[] | null;
appends?: string[] | null;
except?: string[] | null;
sort?: string[] | null;
page?: number;
pageSize?: number;
paginate?: boolean;
}
export interface QueryOptions {
measures?: RecordRow[];
dimensions?: RecordRow[];
orders?: RecordRow[];
filter?: Record<string, unknown>;
having?: Record<string, unknown>;
limit?: number;
offset?: number;
timezone?: string;
}
type CredentialProvider = () => Credentials | Promise<Credentials>;
export class NocoBaseClient {
readonly dataSource: string;
readonly timeoutMs: number;
private readonly credentialProvider: CredentialProvider;
constructor(
credentialProvider: CredentialProvider,
options: { dataSource?: string; timeoutMs?: number } = {},
) {
this.credentialProvider = credentialProvider;
this.dataSource = options.dataSource?.trim() || "main";
this.timeoutMs = options.timeoutMs ?? 30_000;
}
async health(resource: string, idField: string): Promise<Record<string, unknown>> {
const credentials = await this.credentials();
await this.listRecords(resource, { fields: [idField], page: 1, pageSize: 1 });
return { ok: true, endpoint: new URL(credentials.apiBaseUrl).origin, status: "authenticated" };
}
async listRecords(resource: string, options: ListOptions = {}): Promise<ApiResponse> {
return await this.resourceRequest(resource, "list", {
query: {
filter: options.filter,
fields: options.fields,
appends: options.appends,
except: options.except,
sort: options.sort,
page: options.page,
pageSize: options.pageSize,
paginate: options.paginate,
},
});
}
async getRecord(
resource: string,
filterByTk: unknown,
options: Omit<ListOptions, "filter" | "sort" | "page" | "pageSize" | "paginate"> = {},
): Promise<ApiResponse> {
return await this.resourceRequest(resource, "get", {
query: { filterByTk, fields: options.fields, appends: options.appends, except: options.except },
});
}
async createRecord(
resource: string,
values: Record<string, unknown>,
whitelist: string[],
): Promise<ApiResponse> {
validateScalarValues(values);
return await this.resourceRequest(resource, "create", { query: { whitelist }, body: values });
}
async updateRecord(
resource: string,
recordId: unknown,
values: Record<string, unknown>,
whitelist: string[],
): Promise<ApiResponse> {
validateScalarValues(values);
return await this.resourceRequest(resource, "update", {
query: { filterByTk: recordId, whitelist },
body: values,
});
}
async queryRecords(resource: string, options: QueryOptions = {}): Promise<ApiResponse> {
const { timezone, ...body } = options;
return await this.resourceRequest(resource, "query", { body, timezone });
}
async getCollection(
collectionName: string,
options: Pick<ListOptions, "appends" | "except"> = {},
): Promise<ApiResponse> {
return await this.resourceRequest("collections", "get", {
query: { filterByTk: collectionName, appends: options.appends, except: options.except },
});
}
async listCollectionFields(
collectionName: string,
options: Omit<ListOptions, "filter" | "sort"> = {},
): Promise<ApiResponse> {
validateResource(collectionName);
return await this.request(`/collections/${encodeURIComponent(collectionName)}/fields:list`, {
query: {
fields: options.fields,
appends: options.appends,
except: options.except,
page: options.page,
pageSize: options.pageSize,
paginate: options.paginate,
},
});
}
async addAttachmentIds(projectId: unknown, attachmentIds: unknown[]): Promise<void> {
await this.attachmentRelation(projectId, attachmentIds, "add");
}
async removeAttachmentIds(projectId: unknown, attachmentIds: unknown[]): Promise<void> {
await this.attachmentRelation(projectId, attachmentIds, "remove");
}
async uploadAttachment(
path: string,
filename?: string,
mimeType = "application/octet-stream",
): Promise<RecordRow> {
await this.ensureProjectWriteAccess();
const content = await readFile(path);
const form = new FormData();
form.append("file", new Blob([content], { type: mimeType }), filename || basename(path));
const payload = await this.request("/attachments:create", {
query: { attachmentField: "t_projects.f_projects_attachments" },
body: form,
});
if (!isRecord(payload.data)) throw new NocoBaseError(502, "NocoBase did not return an attachment object");
return payload.data;
}
async verifyCredentials(): Promise<void> {
await this.request("/roles:check", { method: "GET" });
}
private async resourceRequest(
resource: string,
action: string,
options: { query?: Record<string, unknown>; body?: unknown; timezone?: string },
): Promise<ApiResponse> {
validateResource(resource);
return await this.request(`/${resource}:${action}`, options);
}
private async attachmentRelation(
projectId: unknown,
attachmentIds: unknown[],
action: "add" | "remove",
): Promise<void> {
const segment = encodeURIComponent(validateIdentifier(projectId, "project_id"));
await this.request(`/t_projects/${segment}/f_projects_attachments:${action}`, {
body: { tk: attachmentIds },
});
}
private async ensureProjectWriteAccess(): Promise<void> {
const payload = await this.request("/roles:check", { method: "GET" });
if (!isRecord(payload.data))
throw new NocoBaseError(502, "NocoBase roles:check did not return role data");
const effective = { ...payload.data };
if (
isRecord(payload.meta) &&
isRecord(payload.meta.dataSources) &&
isRecord(payload.meta.dataSources.main)
) {
Object.assign(effective, payload.meta.dataSources.main);
}
if (roleAllows(effective, "t_projects", "create") || roleAllows(effective, "t_projects", "update"))
return;
throw new NocoBaseError(403, "Current NocoBase role cannot create or update projects");
}
private async credentials(): Promise<Credentials> {
try {
const credentials = await this.credentialProvider();
if (!credentials.apiBaseUrl.trim() || !credentials.authorizeKey.trim())
throw new Error("missing credentials");
return {
apiBaseUrl: credentials.apiBaseUrl.replace(/\/+$/, ""),
authorizeKey: credentials.authorizeKey.trim(),
};
} catch (error) {
throw new NocoBaseError(401, `NocoBase credentials are unavailable: ${errorMessage(error)}`, {
authenticationFailed: true,
});
}
}
private async request(
path: string,
options: { method?: string; query?: Record<string, unknown>; body?: unknown; timezone?: string } = {},
): Promise<ApiResponse> {
const credentials = await this.credentials();
const url = new URL(`${credentials.apiBaseUrl}${path}`);
appendQuery(url, options.query ?? {});
const headers = new Headers({
authorization: `Bearer ${credentials.authorizeKey}`,
"x-request-source": "nocobase-crm-mcp",
});
if (this.dataSource !== "main") headers.set("x-data-source", this.dataSource);
if (options.timezone) headers.set("x-timezone", options.timezone);
let body: BodyInit | undefined;
if (options.body instanceof FormData) body = options.body;
else if (options.body !== undefined) {
headers.set("content-type", "application/json");
body = JSON.stringify(options.body);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetchSameOrigin(url, {
method: options.method ?? "POST",
headers,
body,
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
const redacted = (text || response.statusText || "NocoBase request failed").replaceAll(
credentials.authorizeKey,
"<redacted>",
);
const upper = text.toUpperCase();
throw new NocoBaseError(response.status, redacted, {
authenticationFailed:
response.status === 401 || AUTHENTICATION_CODES.some((code) => upper.includes(code)),
});
}
if (!text) return {};
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
throw new NocoBaseError(response.status, "NocoBase response was not valid JSON");
}
if (!isRecord(payload))
throw new NocoBaseError(response.status, "NocoBase response was not a JSON object");
return payload;
} catch (error) {
if (error instanceof NocoBaseError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new NocoBaseError(408, "NocoBase request timed out", { retryable: true });
}
throw new NocoBaseError(0, `NocoBase request failed: ${errorMessage(error)}`, { retryable: true });
} finally {
clearTimeout(timeout);
}
}
}
function appendQuery(url: URL, query: Record<string, unknown>): void {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const item of value)
url.searchParams.append(key, typeof item === "object" ? JSON.stringify(item) : String(item));
} else {
url.searchParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
}
}
}
async function fetchSameOrigin(initialUrl: URL, init: RequestInit): Promise<Response> {
let url = initialUrl;
for (let redirect = 0; redirect <= 5; redirect += 1) {
const response = await fetch(url, { ...init, redirect: "manual" });
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
const location = response.headers.get("location");
if (!location) return response;
const next = new URL(location, url);
if (next.origin !== initialUrl.origin)
throw new NocoBaseError(502, "NocoBase redirected authentication to another origin");
url = next;
}
throw new NocoBaseError(502, "NocoBase returned too many redirects");
}
function validateResource(resource: string): void {
if (!/^[A-Za-z0-9_.-]+$/.test(resource)) throw new Error(`Invalid NocoBase resource: ${resource}`);
}
export function validateIdentifier(value: unknown, name: string): string {
if ((typeof value !== "string" && typeof value !== "number") || typeof value === "boolean") {
throw new Error(`${name} must be a scalar identifier`);
}
const normalized = String(value);
if (!normalized.trim()) throw new Error(`${name} must not be blank`);
if (normalized === "." || normalized === "..") throw new Error(`${name} must not be a dot path segment`);
return normalized;
}
function validateScalarValues(values: Record<string, unknown>): void {
for (const [key, value] of Object.entries(values)) {
if (value !== null && typeof value === "object") throw new Error(`${key} must be a scalar value`);
}
}
function roleAllows(role: RecordRow, resource: string, action: string): boolean {
if (role.allowAll === true) return true;
const aliases = isRecord(role.actionAlias) ? role.actionAlias : {};
const resolved = typeof aliases[action] === "string" ? aliases[action] : action;
if (Array.isArray(role.resources) && role.resources.includes(resource)) {
if (!isRecord(role.actions)) return false;
const actions = role.actions as RecordRow;
return [`${resource}:${resolved}`, `${resource}:${action}`].some(
(path) => actions[path] !== false && actions[path] != null,
);
}
if (!isRecord(role.strategy) || !Array.isArray(role.strategy.actions)) return false;
return role.strategy.actions.some(
(candidate) => typeof candidate === "string" && candidate.split(":", 1)[0] === resolved,
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+191
View File
@@ -0,0 +1,191 @@
import type { ProjectConfig } from "./config.js";
import { type NocoBaseClient, NocoBaseError, validateIdentifier } from "./nocobase-client.js";
import { isRecord } from "./types.js";
export class ProjectService {
private readonly config: ProjectConfig;
private readonly client: NocoBaseClient;
private readonly whitelist: string[];
constructor(config: ProjectConfig, client: NocoBaseClient) {
this.config = config;
this.client = client;
this.whitelist = [
config.nameField,
config.customerField,
config.inquiryDateField,
config.progressField,
config.applicationsField,
config.partNumbersField,
config.periodUsageField,
config.detailsField,
];
}
async createProject(options: {
name: string;
customerId?: unknown;
inquiryDate?: string;
progress?: string;
applications?: string;
partNumbers?: string;
periodUsage?: string;
details?: string;
attachmentIds?: unknown[];
}): Promise<Record<string, unknown>> {
let values: Record<string, unknown>;
let attachmentIds: unknown[];
try {
values = this.projectValues(options, true);
attachmentIds = deduplicateIdentifiers(options.attachmentIds, "attachment IDs");
} catch (error) {
return inputError(error);
}
const response = await this.client.createRecord(this.config.resource, values, this.whitelist);
if (!isRecord(response.data) || !(this.config.idField in response.data)) {
throw new Error(`NocoBase create response did not contain ${this.config.idField}`);
}
const projectId = response.data[this.config.idField];
if (attachmentIds.length > 0) {
try {
await this.client.addAttachmentIds(projectId, attachmentIds);
} catch (error) {
return attachmentError(projectId, error);
}
}
return { project_id: projectId };
}
async updateProject(
projectId: unknown,
options: {
name?: string;
customerId?: unknown;
inquiryDate?: string;
progress?: string;
applications?: string;
partNumbers?: string;
periodUsage?: string;
details?: string;
addAttachmentIds?: unknown[];
removeAttachmentIds?: unknown[];
},
): Promise<Record<string, unknown>> {
let values: Record<string, unknown>;
let add: unknown[];
let remove: unknown[];
try {
validateIdentifier(projectId, "project_id");
values = this.projectValues(options, false);
add = deduplicateIdentifiers(options.addAttachmentIds, "attachment IDs");
remove = deduplicateIdentifiers(options.removeAttachmentIds, "attachment IDs");
if (add.some((id) => remove.includes(id)))
throw new Error("attachment IDs cannot be added and removed together");
if (Object.keys(values).length === 0 && add.length === 0 && remove.length === 0) {
throw new Error("update request must include project fields or attachment IDs");
}
} catch (error) {
return inputError(error);
}
if (Object.keys(values).length > 0) {
await this.client.updateRecord(this.config.resource, projectId, values, this.whitelist);
}
try {
if (add.length > 0) await this.client.addAttachmentIds(projectId, add);
if (remove.length > 0) await this.client.removeAttachmentIds(projectId, remove);
} catch (error) {
return attachmentError(projectId, error);
}
return { project_id: projectId };
}
private projectValues(
options: {
name?: string;
customerId?: unknown;
inquiryDate?: string;
progress?: string;
applications?: string;
partNumbers?: string;
periodUsage?: string;
details?: string;
},
requireName: boolean,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (options.name !== undefined) {
if (typeof options.name !== "string" || !options.name.trim()) throw new Error("name must not be blank");
values[this.config.nameField] = options.name.trim();
} else if (requireName) throw new Error("name is required");
if (options.customerId !== undefined && options.customerId !== null) {
validateIdentifier(options.customerId, "customer_id");
values[this.config.customerField] = options.customerId;
}
if (options.inquiryDate !== undefined) {
if (!isIsoDate(options.inquiryDate)) throw new Error("inquiry_date must be a YYYY-MM-DD date");
values[this.config.inquiryDateField] = options.inquiryDate;
}
if (options.progress !== undefined) {
const progress = this.config.progressValues[options.progress];
if (!progress) throw new InvalidProgressError();
values[this.config.progressField] = progress;
}
for (const [value, field] of [
[options.applications, this.config.applicationsField],
[options.partNumbers, this.config.partNumbersField],
[options.periodUsage, this.config.periodUsageField],
[options.details, this.config.detailsField],
] as const) {
if (value !== undefined) {
if (typeof value !== "string") throw new Error(`${field} must be a scalar value`);
values[field] = value;
}
}
return values;
}
}
class InvalidProgressError extends Error {}
function inputError(error: unknown): Record<string, unknown> {
if (error instanceof InvalidProgressError) {
return {
error: {
code: "invalid_progress",
message: "progress must be one of the configured project progress labels",
},
};
}
return {
error: { code: "invalid_project_input", message: error instanceof Error ? error.message : String(error) },
};
}
function deduplicateIdentifiers(values: unknown[] | undefined, name: string): unknown[] {
if (values === undefined) return [];
if (!Array.isArray(values)) throw new Error(`${name} must be a list`);
const unique: unknown[] = [];
for (const value of values) {
validateIdentifier(value, name);
if (!unique.includes(value)) unique.push(value);
}
return unique;
}
function attachmentError(projectId: unknown, error: unknown): Record<string, unknown> {
let code = "nb_upload_failed";
if (error instanceof NocoBaseError) {
if (error.statusCode === 404) code = "attachment_not_found";
else if (error.authenticationFailed) code = "nb_upload_unauthorized";
}
return {
project_id: projectId,
error: { code, message: error instanceof Error ? error.message : String(error) },
};
}
function isIsoDate(value: unknown): value is string {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
+447
View File
@@ -0,0 +1,447 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { CrmService } from "./crm-service.js";
import { NocoBaseError } from "./nocobase-client.js";
import type { ProjectService } from "./project-service.js";
import { VERSION } from "./version.js";
type ToolPayload = Record<string, unknown>;
const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
const writeAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
const pagination = {
limit: z.number().int().describe("Maximum rows to return.").default(20),
offset: z.number().int().describe("Zero-based result offset.").default(0),
};
export function createServer(options: {
crmService: CrmService;
projectService?: ProjectService;
readonly?: boolean;
enableConfigTools?: boolean;
}): McpServer {
const server = new McpServer({ name: "nocobase-crm", version: VERSION });
const crm = options.crmService;
register(
server,
"crm_health",
"Check NocoBase reachability, personal Key authentication, and CRM mapping.",
{},
() => crm.crmHealth(),
);
register(
server,
"crm_schema",
"Return the configured CRM collection and field mapping.",
{ include_fields: z.boolean().describe("Try to include collection field metadata.").default(false) },
({ include_fields }) => crm.crmSchema(include_fields),
);
register(
server,
"crm_overview",
"Return CRM totals, common customer distributions, and recent follow-ups.",
{
date_from: z.string().optional().describe("Optional lower follow-up date bound."),
date_to: z.string().optional().describe("Optional upper follow-up date bound."),
owner: z.string().optional().describe("Optional user ownership and participation filter."),
},
({ date_from, date_to, owner }) => crm.crmOverview({ dateFrom: date_from, dateTo: date_to, owner }),
);
register(
server,
"crm_filter_options",
"Return customer filter values, follow-up codes, and optional owner candidates.",
{ owner_query: z.string().optional().describe("Optional owner search text.") },
({ owner_query }) => crm.crmFilterOptions(owner_query),
);
register(
server,
"resolve_owner",
"Resolve an owner username, nickname, email, or English name.",
{ query: z.string().describe("Owner search text.") },
({ query }) => crm.resolveOwner(query),
);
register(
server,
"list_teams",
"List CRM teams and optionally their formal members.",
{
query: z.string().optional().describe("Optional team name search."),
include_members: z.boolean().default(false),
formal_members_only: z.boolean().default(true),
...pagination,
},
({ query, include_members, formal_members_only, limit, offset }) =>
crm.listTeams({
query,
includeMembers: include_members,
formalMembersOnly: formal_members_only,
limit,
offset,
}),
);
register(
server,
"resolve_team",
"Resolve a team name before using it as a CRM filter.",
{ query: z.string().describe("Team name to resolve.") },
({ query }) => crm.resolveTeam(query),
);
register(
server,
"get_team",
"Get one CRM team, its managers, and optionally its members.",
{
team_id: z.union([z.string(), z.number()]).describe("NocoBase team ID."),
include_members: z.boolean().default(true),
formal_members_only: z.boolean().default(true),
},
({ team_id, include_members, formal_members_only }) =>
crm.getTeam(team_id, { includeMembers: include_members, formalMembersOnly: formal_members_only }),
);
register(
server,
"team_crm_overview",
"Return team totals and member contributions using formal CRM team membership.",
{
team: z.string().describe("Exact or uniquely matching CRM team name."),
date_from: z.string().optional(),
date_to: z.string().optional(),
include_member_breakdown: z.boolean().default(true),
},
({ team, date_from, date_to, include_member_breakdown }) =>
crm.teamCrmOverview(team, {
dateFrom: date_from,
dateTo: date_to,
includeMemberBreakdown: include_member_breakdown,
}),
);
register(
server,
"list_customers",
"List CRM customers with readable compact rows and pagination metadata.",
{
query: z.string().optional(),
status: z.string().optional(),
owner: z.string().optional(),
team: z.string().optional(),
source: z.string().optional(),
...pagination,
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listCustomers({
query: args.query,
status: args.status,
owner: args.owner,
team: args.team,
source: args.source,
limit: args.limit,
offset: args.offset,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"get_customer",
"Get one customer with contacts and recent follow-ups.",
{
customer_id: z.union([z.string(), z.number()]),
include_contacts: z.boolean().default(true),
include_recent_followups: z.boolean().default(true),
followup_limit: z.number().int().default(10),
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.getCustomer(args.customer_id, {
includeContacts: args.include_contacts,
includeRecentFollowups: args.include_recent_followups,
followupLimit: args.followup_limit,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"list_contacts",
"List CRM contacts, optionally filtered by customer.",
{
customer_id: z.union([z.string(), z.number()]).optional(),
query: z.string().optional(),
...pagination,
include_details: z.boolean().default(true),
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listContacts({
customerId: args.customer_id,
query: args.query,
limit: args.limit,
offset: args.offset,
includeDetails: args.include_details,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"list_followups",
"List customer follow-up records as compact readable rows.",
{
customer_id: z.union([z.string(), z.number()]).optional(),
contact_id: z.union([z.string(), z.number()]).optional(),
owner: z.string().optional(),
team: z.string().optional(),
project_id: z.union([z.string(), z.number()]).optional(),
followup_type: z.string().optional(),
record_status: z.string().optional(),
date_from: z.string().optional(),
date_to: z.string().optional(),
...pagination,
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listFollowups({
customerId: args.customer_id,
contactId: args.contact_id,
owner: args.owner,
team: args.team,
projectId: args.project_id,
followupType: args.followup_type,
recordStatus: args.record_status,
dateFrom: args.date_from,
dateTo: args.date_to,
limit: args.limit,
offset: args.offset,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"list_projects",
"List CRM projects linked to customers, teams, and project stages.",
{
customer_id: z.union([z.string(), z.number()]).optional(),
team: z.string().optional(),
stage: z.string().optional(),
...pagination,
include_raw: z.boolean().default(false),
},
(args) =>
withRawPolicy(args.include_raw, () =>
crm.listProjects({
customerId: args.customer_id,
team: args.team,
stage: args.stage,
limit: args.limit,
offset: args.offset,
includeRaw: args.include_raw,
}),
),
);
register(
server,
"get_project",
"Get a CRM project with its existing relations.",
{
project_id: z.union([z.string(), z.number()]),
include_contacts: z.boolean().default(true),
include_followups: z.boolean().default(true),
include_updates: z.boolean().default(true),
},
({ project_id, include_contacts, include_followups, include_updates }) =>
crm.getProject(project_id, {
includeContacts: include_contacts,
includeFollowups: include_followups,
includeUpdates: include_updates,
}),
);
register(
server,
"get_project_timeline",
"Return a chronological project timeline from follow-ups and project updates.",
{
project_id: z.union([z.string(), z.number()]),
date_from: z.string().optional(),
date_to: z.string().optional(),
},
({ project_id, date_from, date_to }) =>
crm.getProjectTimeline(project_id, { dateFrom: date_from, dateTo: date_to }),
);
register(
server,
"list_stale_customers",
"List customers whose last valid follow-up is older than the threshold.",
{
team: z.string().optional(),
owner: z.string().optional(),
inactive_days: z.number().int().default(30),
as_of: z.string().optional(),
...pagination,
},
({ team, owner, inactive_days, as_of, limit, offset }) =>
crm.listStaleCustomers({ team, owner, inactiveDays: inactive_days, asOf: as_of, limit, offset }),
);
if (options.enableConfigTools) {
register(server, "crm_config_template", "Build a reviewed config template with field metadata.", {}, () =>
crm.crmConfigTemplate(),
);
}
if (!(options.readonly ?? true)) {
if (!options.projectService) throw new Error("projectService is required when writes are enabled");
registerWriteTools(server, options.projectService);
}
return server;
}
function registerWriteTools(server: McpServer, projects: ProjectService): void {
server.registerTool(
"create_project",
{
description: "Create one fixed-schema project and optionally associate uploaded attachment IDs.",
inputSchema: {
name: z.string().describe("Project name; must not be blank."),
customer_id: z.union([z.string(), z.number()]).optional(),
inquiry_date: z.string().optional(),
progress: z.string().optional(),
applications: z.string().optional(),
part_numbers: z.string().optional(),
period_usage: z.string().optional(),
details: z.string().optional(),
attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
},
annotations: writeAnnotations,
},
async (args) =>
toolResult(
await safeTool(() =>
projects.createProject({
name: args.name,
customerId: args.customer_id,
inquiryDate: args.inquiry_date,
progress: args.progress,
applications: args.applications,
partNumbers: args.part_numbers,
periodUsage: args.period_usage,
details: args.details,
attachmentIds: args.attachment_ids,
}),
),
),
);
server.registerTool(
"update_project",
{
description: "Update fixed project fields and incrementally add or remove attachment IDs.",
inputSchema: {
project_id: z.union([z.string(), z.number()]),
name: z.string().optional(),
customer_id: z.union([z.string(), z.number()]).optional(),
inquiry_date: z.string().optional(),
progress: z.string().optional(),
applications: z.string().optional(),
part_numbers: z.string().optional(),
period_usage: z.string().optional(),
details: z.string().optional(),
add_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
remove_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
},
annotations: writeAnnotations,
},
async (args) =>
toolResult(
await safeTool(() =>
projects.updateProject(args.project_id, {
name: args.name,
customerId: args.customer_id,
inquiryDate: args.inquiry_date,
progress: args.progress,
applications: args.applications,
partNumbers: args.part_numbers,
periodUsage: args.period_usage,
details: args.details,
addAttachmentIds: args.add_attachment_ids,
removeAttachmentIds: args.remove_attachment_ids,
}),
),
),
);
}
function register(
server: McpServer,
name: string,
description: string,
inputSchema: z.ZodRawShape,
operation: (args: any) => Promise<ToolPayload>,
): void {
server.registerTool(name, { description, inputSchema, annotations: readAnnotations }, async (args: any) =>
toolResult(await safeTool(() => operation(args))),
);
}
async function withRawPolicy(
includeRaw: boolean,
operation: () => Promise<ToolPayload>,
): Promise<ToolPayload> {
if (includeRaw && process.env.NOCOBASE_MCP_ENABLE_RAW_DATA?.trim() !== "1") {
return {
error: {
code: "raw_data_disabled",
message: "Raw NocoBase rows are disabled for this local MCP installation.",
hint: "Keep include_raw=false, or explicitly approve NOCOBASE_MCP_ENABLE_RAW_DATA=1.",
retryable: false,
},
};
}
return await operation();
}
async function safeTool(operation: () => Promise<ToolPayload>): Promise<ToolPayload> {
try {
return await operation();
} catch (error) {
if (error instanceof NocoBaseError && error.authenticationFailed) {
return {
error: {
code: "nocobase_auth_failed",
message: error.message,
hint: "Ask the local installation Agent to run setup, then enter a personal Authorize Key in the hidden prompt.",
retryable: false,
},
};
}
if (error instanceof NocoBaseError) {
return {
error: {
code: error.statusCode === 408 ? "nocobase_request_timeout" : "nocobase_request_failed",
message: error.message,
hint: "Check the NocoBase URL, ACL, collection mapping, and query parameters.",
retryable: error.retryable,
},
};
}
return {
error: {
code: "tool_error",
message: error instanceof Error ? error.message : String(error),
hint: "Check the local MCP configuration and logs.",
retryable: false,
},
};
}
}
function toolResult(payload: ToolPayload): CallToolResult {
return {
content: [{ type: "text", text: JSON.stringify(payload) }],
structuredContent: payload,
};
}
+9
View File
@@ -0,0 +1,9 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
export type JsonObject = { [key: string]: JsonValue | undefined };
export type RecordRow = Record<string, unknown>;
export type ApiResponse = Record<string, unknown>;
export function isRecord(value: unknown): value is RecordRow {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+3
View File
@@ -0,0 +1,3 @@
import packageMetadata from "../package.json" with { type: "json" };
export const VERSION = packageMetadata.version;
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { clientConfig, runCli } from "../src/cli.js";
import { VERSION } from "../src/version.js";
test("package, manifest, and runtime versions agree with no runtime npm dependencies", () => {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const manifest = JSON.parse(readFileSync(new URL("../install-manifest.json", import.meta.url), "utf8"));
assert.equal(VERSION, "0.3.0");
assert.equal(packageJson.version, VERSION);
assert.equal(manifest.version, VERSION);
assert.deepEqual(packageJson.dependencies, undefined);
assert.equal(manifest.runtime.runtimeNpmDependencies, 0);
});
test("generated MCP config contains no Key, token, or environment secret", () => {
const config = clientConfig("/private/config.json", false);
const serialized = JSON.stringify(config);
assert.match(serialized, /nocobase-crm/);
assert.match(serialized, /config\.json/);
assert.doesNotMatch(serialized, /authorize|token|secret|password/i);
});
test("setup refuses a non-TTY before creating credential files", async () => {
if (process.stdin.isTTY || process.stderr.isTTY) return;
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-nontty-"));
const path = join(directory, "credentials.json");
const previous = process.env.NOCOBASE_MCP_CREDENTIALS;
process.env.NOCOBASE_MCP_CREDENTIALS = path;
try {
await assert.rejects(
runCli(["setup", "--api-base-url", "https://nb.example", "--json"]),
/local terminal/,
);
assert.equal(existsSync(path), false);
assert.equal(existsSync(join(directory, "config.json")), false);
} finally {
if (previous === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS;
else process.env.NOCOBASE_MCP_CREDENTIALS = previous;
}
});
+169
View File
@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "node:test";
import { NocoBaseClient, NocoBaseError } from "../src/nocobase-client.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
function client(token = "personal-secret", dataSource = "main"): NocoBaseClient {
return new NocoBaseClient(() => ({ apiBaseUrl: "https://nb.example/api", authorizeKey: token }), {
dataSource,
});
}
test("sends list requests with the same HTTP contract as nb resource list", async () => {
let request: Request | undefined;
globalThis.fetch = async (input, init) => {
request = new Request(input, init);
return new Response(JSON.stringify({ data: [], meta: { count: 0 } }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
await client("key", "secondary").listRecords("customers", {
filter: { status: { $eq: "active" } },
fields: ["id", "name"],
page: 2,
pageSize: 20,
});
assert.ok(request);
assert.equal(request.method, "POST");
assert.equal(new URL(request.url).pathname, "/api/customers:list");
assert.equal(
new URL(request.url).searchParams.get("filter"),
JSON.stringify({ status: { $eq: "active" } }),
);
assert.deepEqual(new URL(request.url).searchParams.getAll("fields"), ["id", "name"]);
assert.equal(request.headers.get("authorization"), "Bearer key");
assert.equal(request.headers.get("x-data-source"), "secondary");
});
test("sends aggregation definitions in a JSON body with timezone header", async () => {
let request: Request | undefined;
globalThis.fetch = async (input, init) => {
request = new Request(input, init);
return new Response(JSON.stringify({ data: [{ count: 3 }] }), { status: 200 });
};
await client().queryRecords("customers", {
measures: [{ field: ["id"], aggregation: "count", alias: "count" }],
timezone: "Asia/Shanghai",
});
assert.ok(request);
assert.equal(request.headers.get("x-timezone"), "Asia/Shanghai");
assert.deepEqual(await request.json(), {
measures: [{ field: ["id"], aggregation: "count", alias: "count" }],
});
});
test("reads credentials for every request", async () => {
const seen: string[] = [];
const keys = ["first", "second"];
const rotating = new NocoBaseClient(() => ({
apiBaseUrl: "https://nb.example/api",
authorizeKey: keys.shift() ?? "missing",
}));
globalThis.fetch = async (input, init) => {
seen.push(new Request(input, init).headers.get("authorization") ?? "");
return new Response(JSON.stringify({ data: [] }), { status: 200 });
};
await rotating.listRecords("customers");
await rotating.listRecords("customers");
assert.deepEqual(seen, ["Bearer first", "Bearer second"]);
});
test("redacts Authorize Keys and distinguishes expired auth from ACL denial", async () => {
for (const [status, body, authenticationFailed] of [
[401, "Bearer personal-secret INVALID_TOKEN", true],
[403, "EXPIRED_TOKEN", true],
[403, "NO_PERMISSIONS", false],
] as const) {
globalThis.fetch = async () => new Response(body, { status });
await assert.rejects(client().listRecords("customers"), (error: unknown) => {
assert.ok(error instanceof NocoBaseError);
assert.equal(error.authenticationFailed, authenticationFailed);
assert.doesNotMatch(error.message, /personal-secret/);
return true;
});
}
});
test("rejects cross-origin redirects before forwarding authentication", async () => {
globalThis.fetch = async () =>
new Response(null, { status: 307, headers: { location: "https://evil.example/api" } });
await assert.rejects(client().listRecords("customers"), /another origin/);
});
test("builds data-model and attachment relation paths safely", async () => {
const urls: string[] = [];
globalThis.fetch = async (input) => {
urls.push(String(input));
return new Response(JSON.stringify({ data: [] }), { status: 200 });
};
await client()
.listCollectionFields("crm customers")
.then(
() => assert.fail("invalid collection should fail"),
() => undefined,
);
await client().listCollectionFields("crm_customers");
await client().addAttachmentIds("../project/7?#", [8]);
assert.match(urls[0] ?? "", /collections\/crm_customers\/fields:list/);
assert.match(urls[1] ?? "", /t_projects\/..%2Fproject%2F7%3F%23\/f_projects_attachments:add/);
await assert.rejects(client().addAttachmentIds("..", [8]), /dot path/);
});
test("rejects nested project write values before sending a request", async () => {
globalThis.fetch = async () => assert.fail("request must not be sent");
await assert.rejects(client().createRecord("projects", { unsafe: { nested: true } }, ["unsafe"]), /scalar/);
});
test("checks project ACL before attachment upload", async () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-upload-"));
const path = join(directory, "quote.pdf");
writeFileSync(path, "pdf");
const requests: Request[] = [];
globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
requests.push(request);
if (new URL(request.url).pathname.endsWith("/roles:check")) {
return new Response(
JSON.stringify({ data: { strategy: { actions: ["view", "create"] }, resources: [] } }),
{ status: 200 },
);
}
return new Response(JSON.stringify({ data: { id: 42 } }), { status: 200 });
};
const uploaded = await client().uploadAttachment(path, "quote.pdf", "application/pdf");
assert.equal(uploaded.id, 42);
assert.equal(new URL(requests[0]?.url ?? "").pathname, "/api/roles:check");
assert.equal(new URL(requests[1]?.url ?? "").pathname, "/api/attachments:create");
assert.equal(
new URL(requests[1]?.url ?? "").searchParams.get("attachmentField"),
"t_projects.f_projects_attachments",
);
});
test("denies attachment upload before create when project ACL is missing", async () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-upload-denied-"));
const path = join(directory, "quote.pdf");
writeFileSync(path, "pdf");
let requests = 0;
globalThis.fetch = async () => {
requests += 1;
return new Response(
JSON.stringify({ data: { strategy: { actions: ["view:own", "trigger:own"] }, resources: [] } }),
{ status: 200 },
);
};
await assert.rejects(client().uploadAttachment(path), (error: unknown) => {
assert.ok(error instanceof NocoBaseError);
assert.equal(error.statusCode, 403);
return true;
});
assert.equal(requests, 1);
});
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { DEFAULT_CONFIG, loadConfig } from "../src/config.js";
import { loadCredentials, normalizeApiBaseUrl, saveCredentials } from "../src/credentials.js";
test("normalizes application URLs to the NocoBase API base", () => {
assert.equal(normalizeApiBaseUrl("https://nb.example/app/"), "https://nb.example/app/api");
assert.equal(normalizeApiBaseUrl("http://127.0.0.1:13000/api"), "http://127.0.0.1:13000/api");
assert.throws(() => normalizeApiBaseUrl("http://nb.example"), /HTTPS/i);
assert.throws(() => normalizeApiBaseUrl("https://user:pass@nb.example"), /credentials/i);
});
test("stores and reads credentials from a private file", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credentials-"));
const path = join(directory, "private", "credentials.json");
saveCredentials({ apiBaseUrl: "https://nb.example", authorizeKey: "secret" }, path);
assert.deepEqual(loadCredentials(path), {
apiBaseUrl: "https://nb.example/api",
authorizeKey: "secret",
});
assert.match(readFileSync(path, "utf8"), /secret/);
if (process.platform !== "win32") {
chmodSync(path, 0o644);
assert.throws(() => loadCredentials(path), /0600/);
}
});
test("loads camelCase and snake_case config aliases without mutating defaults", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-config-"));
const path = join(directory, "config.json");
writeFileSync(
path,
JSON.stringify({
data_source: "secondary",
timezone: "UTC",
collections: { customers: { resource: "crm_customers", owner_field: "assignee" } },
projects: { nameField: "project_title" },
}),
);
const loaded = loadConfig(path);
assert.equal(loaded.dataSource, "secondary");
assert.equal(loaded.customer.resource, "crm_customers");
assert.equal(loaded.customer.ownerField, "assignee");
assert.equal(loaded.project.nameField, "project_title");
assert.equal(DEFAULT_CONFIG.customer.resource, "customers");
});
test("rejects malformed config types before any NocoBase request", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-invalid-config-"));
const path = join(directory, "config.json");
writeFileSync(path, JSON.stringify({ collections: { customers: { fields: "id,name" } } }));
assert.throws(() => loadConfig(path), /array of strings/);
});
+193
View File
@@ -0,0 +1,193 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_CONFIG } from "../src/config.js";
import { CrmService } from "../src/crm-service.js";
import type { NocoBaseClient } from "../src/nocobase-client.js";
test("renders compact customer rows, labels, relations, timezone, and pagination", async () => {
const requests: Array<Record<string, unknown>> = [];
const fake = {
listRecords: async (resource: string, options: Record<string, unknown>) => {
requests.push({ resource, ...options });
return {
data: [
{
id: 42,
f_q2q60q7bqbg: "Acme",
kh_progress: "kh_p_inprogress",
f_225h6n6v654: "Referral",
kehu_followup_person: [{ id: 7, nickname: "Alice" }],
updatedAt: "2026-07-08T09:00:00Z",
},
],
meta: { count: 1 },
};
},
} as unknown as NocoBaseClient;
const config = structuredClone(DEFAULT_CONFIG);
config.customer.resource = "crm_customers";
config.customer.fields = [
"id",
"f_q2q60q7bqbg",
"kh_progress",
"f_225h6n6v654",
"kehu_followup_person",
"updatedAt",
];
config.customer.statusField = "kh_progress";
config.customer.sourceField = "f_225h6n6v654";
config.customer.ownerField = "kehu_followup_person";
const result = await new CrmService(config, fake).listCustomers({ limit: 20 });
assert.deepEqual(result.results, [
{
id: 42,
name: "Acme",
status: "进行中",
source: "Referral",
owners: [{ id: 7, name: "Alice" }],
updatedAt: "2026-07-08T17:00:00+08:00",
},
]);
assert.equal(result.total, 1);
assert.equal(result.timezone, "Asia/Shanghai");
assert.equal(requests[0]?.resource, "crm_customers");
});
test("implements exact arbitrary offsets across two NocoBase pages", async () => {
const pages: number[] = [];
const fake = {
listRecords: async (_resource: string, options: Record<string, unknown>) => {
const page = options.page as number;
pages.push(page);
const start = (page - 1) * 20;
return {
data: Array.from({ length: 20 }, (_, index) => ({
id: start + index,
name: `Customer ${start + index}`,
})),
meta: { count: 100 },
};
},
} as unknown as NocoBaseClient;
const result = await new CrmService(structuredClone(DEFAULT_CONFIG), fake).listCustomers({
limit: 20,
offset: 25,
});
assert.deepEqual(pages, [2, 3]);
assert.deepEqual(
(result.results as Array<Record<string, unknown>>).map((row) => row.id),
Array.from({ length: 20 }, (_, i) => 25 + i),
);
assert.equal(result.next_offset, 45);
});
test("resolves owner candidates and returns ambiguity as structured data", async () => {
const fake = {
listRecords: async (resource: string) => {
if (resource === "users") {
return {
data: [
{ id: 1, username: "alex", nickname: "Alex A" },
{ id: 2, username: "alex", nickname: "Alex B" },
],
};
}
return { data: [], meta: { count: 0 } };
},
} as unknown as NocoBaseClient;
const service = new CrmService(structuredClone(DEFAULT_CONFIG), fake);
const resolved = await service.resolveOwner("alex");
assert.equal(resolved.count, 2);
const listed = await service.listCustomers({ owner: "alex" });
assert.equal((listed.error as Record<string, unknown>).code, "owner_ambiguous");
});
test("health never returns the Authorize Key", async () => {
const fake = {
health: async () => ({ ok: true, endpoint: "https://nb.example", status: "authenticated" }),
} as unknown as NocoBaseClient;
const result = await new CrmService(structuredClone(DEFAULT_CONFIG), fake).crmHealth();
assert.equal(result.ok, true);
assert.doesNotMatch(JSON.stringify(result), /token|secret|authorizeKey/i);
});
test("all CRM service surfaces execute against the direct HTTP adapter contract", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const rows: Record<string, Record<string, unknown>> = {
[config.customer.resource]: {
id: 1,
name: "Acme",
owner: [{ id: 10, nickname: "Alice" }],
updatedAt: "2026-01-01T00:00:00Z",
},
[config.contact.resource]: { id: 2, name: "Bob", customer: [{ id: 1, name: "Acme" }] },
[config.followup.resource]: {
id: 3,
followedAt: "2026-01-02T00:00:00Z",
customer: [{ id: 1, name: "Acme" }],
owner: [{ id: 10, nickname: "Alice" }],
content: "Followed up",
},
[config.user.resource]: {
id: 10,
username: "alice",
nickname: "Alice",
[config.team.userStatusField]: config.team.formalUserStatusValue,
},
[config.team.resource]: {
id: 20,
[config.team.nameField]: "Alpha",
[config.team.managerField]: [{ id: 10 }],
[config.team.memberField]: [{ id: 10 }],
},
[config.project.resource]: {
id: 30,
[config.project.nameField]: "Project",
[config.project.progressField]: config.project.progressValues.已报价,
[config.project.customerRelationField]: [{ id: 1, name: "Acme" }],
},
[config.project.updateResource]: {
id: 31,
[config.project.updateDateField]: "2026-01-03T00:00:00Z",
[config.project.updateContentField]: "Updated",
createdBy: [{ id: 10, nickname: "Alice" }],
},
};
const fake = {
health: async () => ({ ok: true, endpoint: "https://nb.example", status: "authenticated" }),
listCollectionFields: async () => ({ data: [] }),
getRecord: async (resource: string) => ({ data: rows[resource] }),
listRecords: async (resource: string) => ({
data: rows[resource] ? [rows[resource]] : [],
meta: { count: rows[resource] ? 1 : 0 },
}),
queryRecords: async (_resource: string, options: Record<string, unknown>) => {
const dimensions = options.dimensions as Array<Record<string, unknown>> | undefined;
const alias = dimensions?.[0]?.alias;
return { data: [{ ...(typeof alias === "string" ? { [alias]: "value" } : {}), count: 1 }] };
},
} as unknown as NocoBaseClient;
const service = new CrmService(config, fake);
const results = await Promise.all([
service.crmHealth(),
service.crmSchema(true),
service.crmConfigTemplate(),
service.listCustomers(),
service.getCustomer(1),
service.listContacts(),
service.listFollowups(),
service.listProjects(),
service.getProject(30),
service.resolveOwner("alice"),
service.listTeams({ includeMembers: true }),
service.resolveTeam("Alpha"),
service.getTeam(20),
service.crmOverview(),
service.crmFilterOptions("alice"),
service.teamCrmOverview("Alpha"),
service.getProjectTimeline(30),
service.listStaleCustomers({ asOf: "2026-08-15", inactiveDays: 30 }),
]);
assert.equal(results.length, 18);
assert.ok(results.every((result) => !("error" in result)));
});
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
fieldsWithProjections,
normalizePagination,
prune,
relationRefs,
toZonedIso,
} from "../src/crm-helpers.js";
import {
buildAndFilter,
buildDateRangeCondition,
buildRelationIdCondition,
buildRelationIdInCondition,
buildSearchFilter,
} from "../src/filters.js";
test("builds nested NocoBase filters", () => {
assert.deepEqual(buildRelationIdCondition("owner", 7), { owner: { id: { $eq: 7 } } });
assert.deepEqual(buildRelationIdCondition(["customer", "owner", "id"], 9), {
customer: { owner: { id: { $eq: 9 } } },
});
assert.deepEqual(buildRelationIdInCondition("owner", [1, 2]), { owner: { id: { $in: [1, 2] } } });
assert.deepEqual(buildDateRangeCondition("createdAt", "2026-01-01", "2026-01-31"), {
createdAt: { $dateBetween: ["2026-01-01", "2026-01-31"] },
});
assert.deepEqual(buildSearchFilter("Acme", ["name", "code"]), {
$or: [{ name: { $includes: "Acme" } }, { code: { $includes: "Acme" } }],
});
assert.deepEqual(buildAndFilter([undefined]), {});
});
test("normalizes pagination and relation projections", () => {
assert.deepEqual(normalizePagination(500, -3), [100, 0]);
assert.deepEqual(fieldsWithProjections(["id", "owner"], ["owner.id", "owner.nickname"]), [
"id",
"owner.id",
"owner.nickname",
]);
});
test("renders relation references without treating an id as a real name", () => {
const refs = relationRefs(
{ customers: [{ id: 42 }, { id: 43, title: "Acme" }, { id: 43, title: "duplicate" }] },
["customers"],
{ idField: "id", nameFields: ["title"] },
);
assert.deepEqual(refs, [
{ id: 42, name: "#42" },
{ id: 43, name: "Acme" },
]);
assert.deepEqual(prune({ id: 1, name: "", rows: [], value: null, active: false }), {
id: 1,
active: false,
});
});
test("formats timestamps in the configured timezone while preserving dates", () => {
assert.equal(toZonedIso("2026-07-08T09:00:00Z", "Asia/Shanghai"), "2026-07-08T17:00:00+08:00");
assert.equal(toZonedIso("2026-07-08", "Asia/Shanghai"), "2026-07-08");
});
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_CONFIG } from "../src/config.js";
import type { NocoBaseClient } from "../src/nocobase-client.js";
import { ProjectService } from "../src/project-service.js";
test("creates fixed-schema projects and translates progress labels", async () => {
const calls: unknown[][] = [];
const fake = {
createRecord: async (...args: unknown[]) => {
calls.push(args);
return { data: { id: 42 } };
},
addAttachmentIds: async (...args: unknown[]) => calls.push(args),
} as unknown as NocoBaseClient;
const service = new ProjectService(DEFAULT_CONFIG.project, fake);
assert.deepEqual(
await service.createProject({
name: " EV inverter ",
progress: "已报价",
customerId: 7,
attachmentIds: [8, 8, 9],
}),
{ project_id: 42 },
);
assert.ok(calls[0]);
assert.deepEqual(
(calls[0][1] as Record<string, unknown>)[DEFAULT_CONFIG.project.progressField],
"k4w2vcz819w",
);
assert.deepEqual(calls[1], [42, [8, 9]]);
});
test("validates project inputs before writes", async () => {
const fake = {
createRecord: async () => assert.fail("must not write"),
updateRecord: async () => assert.fail("must not write"),
} as unknown as NocoBaseClient;
const service = new ProjectService(DEFAULT_CONFIG.project, fake);
assert.equal(
((await service.createProject({ name: " " })).error as Record<string, unknown>).code,
"invalid_project_input",
);
assert.equal(
((await service.createProject({ name: "Project", progress: "unknown" })).error as Record<string, unknown>)
.code,
"invalid_progress",
);
assert.equal(
(
(await service.updateProject(1, { addAttachmentIds: [8], removeAttachmentIds: [8] })).error as Record<
string,
unknown
>
).code,
"invalid_project_input",
);
});
+97
View File
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { CrmService } from "../src/crm-service.js";
import type { ProjectService } from "../src/project-service.js";
import { createServer } from "../src/tools.js";
function fakeService(): CrmService {
return new Proxy(
{},
{
get:
(_target, property) =>
async (...args: unknown[]) => ({ operation: String(property), args }),
},
) as CrmService;
}
async function connected(readonly: boolean): Promise<{
client: Client;
close: () => Promise<void>;
}> {
const server = createServer({
crmService: fakeService(),
projectService: fakeService() as unknown as ProjectService,
readonly,
});
const client = new Client({ name: "test", version: "1" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
return {
client,
close: async () => await Promise.all([client.close(), server.close()]).then(() => undefined),
};
}
test("read-only server exposes the stable 17-tool contract", async () => {
const connection = await connected(true);
try {
const result = await connection.client.listTools();
assert.deepEqual(
new Set(result.tools.map((tool) => tool.name)),
new Set([
"crm_health",
"crm_schema",
"crm_overview",
"crm_filter_options",
"resolve_owner",
"list_teams",
"resolve_team",
"get_team",
"team_crm_overview",
"list_customers",
"get_customer",
"list_contacts",
"list_followups",
"list_projects",
"get_project",
"get_project_timeline",
"list_stale_customers",
]),
);
} finally {
await connection.close();
}
});
test("write mode adds only fixed project create and update tools", async () => {
const connection = await connected(false);
try {
const names = (await connection.client.listTools()).tools.map((tool) => tool.name);
assert.ok(names.includes("create_project"));
assert.ok(names.includes("update_project"));
assert.ok(!names.some((name) => name.includes("destroy") || name.includes("delete")));
} finally {
await connection.close();
}
});
test("raw rows require explicit local policy opt-in", async () => {
const previous = process.env.NOCOBASE_MCP_ENABLE_RAW_DATA;
delete process.env.NOCOBASE_MCP_ENABLE_RAW_DATA;
const connection = await connected(true);
try {
const result = await connection.client.callTool({
name: "list_customers",
arguments: { include_raw: true },
});
const structured = result.structuredContent as { error: Record<string, unknown> };
assert.equal(structured.error.code, "raw_data_disabled");
} finally {
if (previous === undefined) delete process.env.NOCOBASE_MCP_ENABLE_RAW_DATA;
else process.env.NOCOBASE_MCP_ENABLE_RAW_DATA = previous;
await connection.close();
}
});
-383
View File
@@ -1,383 +0,0 @@
import json
import subprocess
import sys
import tempfile
import tomllib
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import agent_install # noqa: E402
def make_project(root: Path) -> None:
(root / "pyproject.toml").write_text('[project]\nname = "test"\n', encoding="utf-8")
(root / "uv.lock").write_text("version = 1\n", encoding="utf-8")
(root / "config.example.json").write_text(json.dumps({"env": "nb-mcp", "collections": {}}), encoding="utf-8")
def fake_which(name: str) -> str | None:
return {
"git": "/usr/bin/git",
"uv": "/opt/bin/uv",
"nb": "/opt/bin/nb",
}.get(name)
class AgentInstallTests(unittest.TestCase):
def test_normalize_api_base_url_requires_tls_for_remote_keys(self) -> None:
self.assertEqual(
agent_install.normalize_api_base_url("https://nb.example/nocobase/"),
"https://nb.example/nocobase/api",
)
self.assertEqual(agent_install.normalize_api_base_url("http://127.0.0.1:13000"), "http://127.0.0.1:13000/api")
with self.assertRaisesRegex(agent_install.InstallerError, "https"):
agent_install.normalize_api_base_url("http://nb.example")
with self.assertRaisesRegex(agent_install.InstallerError, "must not contain credentials"):
agent_install.normalize_api_base_url("https://user:secret@nb.example")
def test_inspect_reports_required_tools_and_config_as_optional_before_prepare(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
result = agent_install.inspect_project(root, which=fake_which, python_version=(3, 12, 4))
self.assertTrue(result["ok"])
checks = {check["name"]: check for check in result["checks"]}
self.assertTrue(checks["uv"]["ok"])
self.assertFalse(checks["config"]["ok"])
self.assertFalse(checks["config"]["required"])
def test_guided_install_composes_runtime_key_doctor_and_client_config(self) -> None:
calls = []
info_reads = 0
def runner(command, **kwargs):
nonlocal info_reads
calls.append((command, kwargs))
if command[1:4] == ["env", "info", "nb-mcp"]:
info_reads += 1
if info_reads == 1:
return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured')
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": "token"}}}),
stderr="",
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
result = agent_install.install_project(
root,
"https://nb.example",
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
stdin_isatty=lambda: True,
)
configured = json.loads((root / "config.json").read_text(encoding="utf-8"))
commands = [command for command, _kwargs in calls]
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "ready-for-client-registration")
self.assertEqual(result["mode"], "read-only")
self.assertEqual(result["prepared"]["dependencyProfile"], "locked-runtime-only")
self.assertEqual(result["authentication"]["keyExposure"], "private-tty-only")
self.assertEqual(configured["env"], "nb-mcp")
self.assertEqual(
result["clientConfig"]["mcpServers"]["nocobase-crm"]["command"],
str(root.resolve() / ".venv" / "bin" / "python"),
)
self.assertEqual(commands[0][1:5], ["sync", "--locked", "--no-dev", "--python"])
self.assertTrue(any("crm_health" in action for action in result["remainingActions"]))
self.assertNotIn("access-token", json.dumps(result))
def test_guided_install_refuses_non_tty_before_writing(self) -> None:
calls = []
def runner(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"):
agent_install.install_project(
root,
"https://nb.example",
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
stdin_isatty=lambda: False,
)
self.assertFalse((root / "config.json").exists())
self.assertEqual(calls, [])
def test_cli_exposes_one_guided_install_command(self) -> None:
args = agent_install.build_parser().parse_args(["install", "--api-base-url", "https://nb.example", "--json"])
self.assertEqual(args.command, "install")
self.assertEqual(args.env_name, "nb-mcp")
self.assertFalse(args.enable_writes)
self.assertTrue(args.as_json)
def test_prepare_is_idempotent_and_never_overwrites_existing_config(self) -> None:
calls = []
def runner(command, **kwargs):
calls.append((command, kwargs))
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
first = agent_install.prepare_project(root, runner=runner, which=fake_which, python_version=(3, 12, 4))
config_path = root / "config.json"
config_path.write_text(json.dumps({"env": "custom-env"}), encoding="utf-8")
second = agent_install.prepare_project(root, runner=runner, which=fake_which, python_version=(3, 12, 4))
preserved = json.loads(config_path.read_text(encoding="utf-8"))
self.assertTrue(first["configCreated"])
self.assertFalse(second["configCreated"])
self.assertEqual(preserved["env"], "custom-env")
self.assertEqual(calls[0][0], ["/opt/bin/uv", "sync", "--locked", "--no-dev", "--python", "3.12"])
self.assertEqual(calls[0][1]["env"]["UV_PROJECT_ENVIRONMENT"], str(root / ".venv"))
self.assertEqual(first["dependencies"], "locked-runtime-only")
self.assertEqual(first["environmentPath"], str(root / ".venv"))
self.assertEqual(len(calls), 2)
def test_set_env_backs_up_config_before_changing_it(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
original = {"env": "nb-mcp", "collections": {"customers": {"resource": "customers"}}}
(root / "config.json").write_text(json.dumps(original), encoding="utf-8")
result = agent_install.set_config_env(root, "nb-mcp-acme")
changed = json.loads((root / "config.json").read_text(encoding="utf-8"))
backup = json.loads((root / "config.json.pre-agent-install.bak").read_text(encoding="utf-8"))
self.assertTrue(result["changed"])
self.assertEqual(changed["env"], "nb-mcp-acme")
self.assertEqual(backup, original)
def test_connect_key_creates_env_through_private_tty_without_key_in_argv(self) -> None:
calls = []
info_reads = 0
def runner(command, **kwargs):
nonlocal info_reads
calls.append((command, kwargs))
if command[1:4] == ["env", "info", "nb-mcp"]:
info_reads += 1
if info_reads == 1:
return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured')
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps(
{
"api": {
"apiBaseUrl": "https://nb.example/api",
"auth": {"type": "token", "accessToken": "-"},
}
}
),
stderr="",
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "old-env"}), encoding="utf-8")
result = agent_install.connect_with_personal_key(
root,
"https://nb.example",
runner=runner,
which=fake_which,
stdin_isatty=lambda: True,
)
configured = json.loads((root / "config.json").read_text(encoding="utf-8"))
commands = [command for command, _kwargs in calls]
flattened = [item for command in commands for item in command]
self.assertEqual(result["action"], "created")
self.assertEqual(result["keyExposure"], "private-tty-only")
self.assertEqual(configured["env"], "nb-mcp")
self.assertNotIn("--access-token", flattened)
self.assertIn(
[
"/opt/bin/nb",
"env",
"add",
"nb-mcp",
"--api-base-url",
"https://nb.example/api",
"--auth-type",
"token",
],
commands,
)
interactive_kwargs = calls[1][1]
self.assertNotIn("capture_output", interactive_kwargs)
def test_connect_key_reauthenticates_matching_env_and_refuses_name_collision(self) -> None:
calls = []
auth_complete = False
def matching_runner(command, **kwargs):
nonlocal auth_complete
calls.append(command)
if command[1:3] == ["env", "info"]:
auth_type = "token" if auth_complete else "oauth"
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": auth_type}}}),
stderr="",
)
if command[1:3] == ["env", "auth"]:
auth_complete = True
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
result = agent_install.connect_with_personal_key(
root,
"https://nb.example/api",
runner=matching_runner,
which=fake_which,
stdin_isatty=lambda: True,
)
self.assertEqual(result["action"], "reauthenticated")
self.assertIn(["/opt/bin/nb", "env", "auth", "nb-mcp", "--auth-type", "token"], calls)
def conflicting_runner(command, **kwargs):
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://other.example/api", "auth": {"type": "token"}}}),
stderr="",
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
with self.assertRaisesRegex(agent_install.InstallerError, "instead of overwriting"):
agent_install.connect_with_personal_key(
root,
"https://nb.example",
runner=conflicting_runner,
which=fake_which,
stdin_isatty=lambda: True,
)
def test_connect_key_requires_user_controlled_tty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"):
agent_install.connect_with_personal_key(
Path(tmp),
"https://nb.example",
which=fake_which,
stdin_isatty=lambda: False,
)
def test_client_config_uses_absolute_paths_and_is_read_only_by_default(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = agent_install.render_client_config(root, which=fake_which)
writable = agent_install.render_client_config(root, enable_writes=True, which=fake_which)
server = config["mcpServers"]["nocobase-crm"]
self.assertEqual(server["command"], str(root.resolve() / ".venv" / "bin" / "python"))
self.assertEqual(server["env"]["NOCOBASE_MCP_NB_BIN"], "/opt/bin/nb")
self.assertIn(str(root.resolve() / "config.json"), server["args"])
self.assertEqual(server["args"][:2], ["-m", "nocobase_crm_mcp.server"])
self.assertNotIn("--enable-writes", server["args"])
self.assertIn("--enable-writes", writable["mcpServers"]["nocobase-crm"]["args"])
def test_client_config_uses_windows_project_environment_python(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = agent_install.render_client_config(root, which=fake_which, os_name="nt")
server = config["mcpServers"]["nocobase-crm"]
self.assertEqual(server["command"], str(root.resolve() / ".venv" / "Scripts" / "python.exe"))
def test_doctor_checks_local_install_without_reading_nb_secrets(self) -> None:
commands = []
def runner(command, **kwargs):
commands.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
result = agent_install.doctor_project(
root,
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
)
self.assertTrue(result["ok"])
self.assertEqual(commands[0], ["/opt/bin/uv", "lock", "--check"])
self.assertEqual(
commands[1],
[
str(root.resolve() / ".venv" / "bin" / "python"),
"-c",
"import mcp; import nocobase_crm_mcp",
],
)
self.assertNotIn("--show-secrets", json.dumps(result))
self.assertTrue(any("crm_health" in step for step in result["remainingVerification"]))
def test_release_manifest_version_matches_package_version(self) -> None:
root = Path(__file__).resolve().parent.parent
manifest = json.loads((root / "install-manifest.json").read_text(encoding="utf-8"))
package = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
version = package["project"]["version"]
self.assertEqual(manifest["version"], version)
self.assertEqual(manifest["distribution"]["releaseRef"], f"v{version}")
self.assertEqual(manifest["authentication"]["type"], "personal-api-key")
self.assertEqual(manifest["authentication"]["secretEntry"], "user-controlled-private-tty")
self.assertFalse(manifest["authentication"]["allowPassword"])
self.assertFalse(manifest["authentication"]["allowCommandLineSecret"])
self.assertEqual(manifest["runtime"]["launcher"], "project-venv-python")
self.assertEqual(manifest["runtime"]["dependencyProfile"], "runtime-only-no-dev")
self.assertEqual(manifest["entrypoints"]["guidedCommand"], "install")
self.assertFalse(manifest["installation"]["defaultBackgroundService"])
self.assertTrue(manifest["securityDefaults"]["readOnly"])
self.assertFalse(manifest["securityDefaults"]["uploads"])
if __name__ == "__main__":
unittest.main()
-253
View File
@@ -1,253 +0,0 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from nocobase_crm_mcp.config import load_config
from nocobase_crm_mcp.filters import (
build_and_filter,
build_date_range_condition,
build_relation_id_condition,
build_relation_id_in_condition,
build_relation_id_or_condition,
build_search_filter,
)
class ConfigTests(unittest.TestCase):
def test_load_config_uses_crm_defaults(self) -> None:
with patch.dict(os.environ, {}, clear=True):
config = load_config(None)
self.assertEqual(config.env_name, "nb-mcp")
self.assertEqual(config.data_source, "main")
self.assertEqual(config.customer.resource, "customers")
self.assertEqual(config.contact.resource, "contacts")
self.assertEqual(config.followup.resource, "followups")
self.assertIn("name", config.customer.search_fields)
self.assertEqual(config.contact.customer_field, "customer")
self.assertEqual(config.followup.customer_field, "customer")
self.assertEqual(config.timezone, "Asia/Shanghai")
self.assertEqual(config.team.resource, "t_team_management")
self.assertEqual(config.team.formal_user_status_value, "nkluuumb1r1")
# Label resolution order: the first non-empty field wins. f_kh_english_name
# must stay in the list -- leaving it out is what made customer 4249 render
# as its own id.
self.assertIn("f_kh_english_name", config.customer.name_fields)
self.assertIn("f_territorial_name", config.customer.name_fields)
self.assertEqual(config.customer.manager_field, "kehu_manage_person")
self.assertEqual(config.contact.name_fields[0], "f_2zpg91gtmwp")
self.assertEqual(config.user.label_fields[0], "nickname")
self.assertNotIn("id", config.user.label_fields)
self.assertEqual(config.followup.content_field, "f_19iplxz0nds")
self.assertEqual(config.followup.location_field, "f_b5c4hfi5og6")
self.assertEqual(config.project.name_fields[0], "f_project_name")
self.assertEqual(config.project.customer_relation_field, "f_projects_client")
def test_default_collection_lists_are_not_shared_between_configs(self) -> None:
with patch.dict(os.environ, {}, clear=True):
first = load_config(None)
second = load_config(None)
assert first.customer.fields is not None
assert second.customer.fields is not None
first.customer.fields.append("local-only")
self.assertNotIn("local-only", second.customer.fields)
def test_load_config_reads_label_and_detail_field_overrides(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"users": {"labelFields": ["email"]},
"collections": {
"customers": {
"nameFields": ["company_name"],
"managerField": "account_manager",
"detailFields": {"address": "billing_address"},
},
"contacts": {"nameFields": ["full_name"], "phoneField": "mobile"},
"followups": {"contentField": "notes", "locationField": "venue"},
},
}
),
encoding="utf-8",
)
config = load_config(config_path)
self.assertEqual(config.customer.name_fields, ["company_name"])
self.assertEqual(config.customer.manager_field, "account_manager")
self.assertEqual(config.customer.detail_fields, {"address": "billing_address"})
self.assertEqual(config.contact.name_fields, ["full_name"])
self.assertEqual(config.contact.phone_field, "mobile")
self.assertEqual(config.followup.content_field, "notes")
self.assertEqual(config.followup.location_field, "venue")
self.assertEqual(config.user.label_fields, ["email"])
def test_project_name_fields_follow_a_renamed_name_field(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps({"projects": {"nameField": "project_title"}}),
encoding="utf-8",
)
config = load_config(config_path)
self.assertEqual(config.project.name_fields[0], "project_title")
def test_load_config_merges_file_and_environment_overrides(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"env": "file-env",
"dataSource": "analytics",
"collections": {
"customers": {
"resource": "crmAccounts",
"fields": ["id", "displayName"],
"searchFields": ["displayName", "shortName"],
},
"contacts": {"resource": "crmContacts", "customerField": "account"},
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"NOCOBASE_MCP_ENV": "env-override"}, clear=True):
config = load_config(config_path)
self.assertEqual(config.env_name, "env-override")
self.assertEqual(config.data_source, "analytics")
self.assertEqual(config.customer.resource, "crmAccounts")
self.assertEqual(config.customer.fields, ["id", "displayName"])
self.assertEqual(config.customer.search_fields, ["displayName", "shortName"])
self.assertEqual(config.contact.resource, "crmContacts")
self.assertEqual(config.contact.customer_field, "account")
self.assertEqual(config.followup.resource, "followups")
def test_load_config_supports_user_and_multiple_followup_owner_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"users": {
"resource": "staff",
"fields": ["id", "username", "nickname"],
"matchFields": ["username", "nickname"],
},
"collections": {
"followups": {
"ownerFields": ["participants", "createdBy"],
},
},
}
),
encoding="utf-8",
)
config = load_config(config_path)
self.assertEqual(config.user.resource, "staff")
self.assertEqual(config.user.fields, ["id", "username", "nickname"])
self.assertEqual(config.user.match_fields, ["username", "nickname"])
self.assertEqual(config.followup.owner_fields, ["participants", "createdBy"])
class FilterBuilderTests(unittest.TestCase):
def test_build_search_filter_uses_or_includes_conditions(self) -> None:
self.assertEqual(
build_search_filter("Acme", ["name", "code"]),
{
"$or": [
{"name": {"$includes": "Acme"}},
{"code": {"$includes": "Acme"}},
]
},
)
def test_build_and_filter_wraps_conditions_and_skips_empty_values(self) -> None:
result = build_and_filter(
[
{"status": {"$eq": "active"}},
None,
{},
build_search_filter("Acme", ["name"]),
]
)
self.assertEqual(
result,
{
"$and": [
{"status": {"$eq": "active"}},
{"$or": [{"name": {"$includes": "Acme"}}]},
]
},
)
def test_build_date_range_condition_uses_nocobase_date_between(self) -> None:
self.assertEqual(
build_date_range_condition("followedAt", "2026-07-01", "2026-07-08"),
{"followedAt": {"$dateBetween": ["2026-07-01", "2026-07-08"]}},
)
self.assertEqual(
build_date_range_condition("followedAt", "2026-07-01", None),
{"followedAt": {"$dateNotBefore": "2026-07-01"}},
)
self.assertEqual(
build_date_range_condition("followedAt", None, "2026-07-08"),
{"followedAt": {"$dateNotAfter": "2026-07-08"}},
)
def test_build_relation_id_condition_supports_nested_dimension_paths(self) -> None:
self.assertEqual(
build_relation_id_condition("kehu_followup_person", 54),
{"kehu_followup_person": {"id": {"$eq": 54}}},
)
self.assertEqual(
build_relation_id_condition("kehu_followup_person.id", 54),
{"kehu_followup_person": {"id": {"$eq": 54}}},
)
self.assertEqual(
build_relation_id_condition(["kehu_followup_person", "id"], 54),
{"kehu_followup_person": {"id": {"$eq": 54}}},
)
def test_build_relation_id_or_condition_filters_multiple_owner_fields(self) -> None:
self.assertEqual(
build_relation_id_or_condition(["gjjl_followup_person", "createdBy"], 54),
{
"$or": [
{"gjjl_followup_person": {"id": {"$eq": 54}}},
{"createdBy": {"id": {"$eq": 54}}},
]
},
)
def test_build_relation_id_in_condition_supports_team_members(self) -> None:
self.assertEqual(
build_relation_id_in_condition("kehu_followup_person", [3, 7]),
{"kehu_followup_person": {"id": {"$in": [3, 7]}}},
)
self.assertEqual(
build_relation_id_in_condition(["f_projects_client", "kehu_followup_person", "id"], [3, 7]),
{
"f_projects_client": {
"kehu_followup_person": {
"id": {"$in": [3, 7]},
}
}
},
)
if __name__ == "__main__":
unittest.main()
-881
View File
@@ -1,881 +0,0 @@
import json
import re
import tempfile
import unittest
from pathlib import Path
from nocobase_crm_mcp.config import load_config
from nocobase_crm_mcp.crm import PROJECTION_FALLBACK_WARNING, CrmService, _prune
from nocobase_crm_mcp.nb_client import NbCommandError
def project_row(row, fields):
"""Shape a canned row the way NocoBase shapes a projected response.
The fake client used to ignore `fields` entirely, which meant a missing
relation projection was invisible to the tests -- the canned rows already
looked projected. Honouring `fields` here is what makes assertions like
"phone never reaches the response" mean anything.
"""
if not fields:
return dict(row)
projected = {}
for path in fields:
head, _, leaf = path.partition(".")
if head not in row:
continue
value = row[head]
if not leaf:
projected[head] = value
continue
if isinstance(value, list):
nested = projected.setdefault(head, [{} for _ in value])
for slot, item in zip(nested, value):
if isinstance(item, dict) and leaf in item:
slot[leaf] = item[leaf]
elif isinstance(value, dict) and leaf in value:
projected.setdefault(head, {})[leaf] = value[leaf]
return projected
class FakeNbClient:
def __init__(self) -> None:
self.list_calls = []
self.get_calls = []
self.query_calls = []
self.field_calls = []
self.health_calls = []
def health(self, resource, id_field):
self.health_calls.append((resource, id_field))
return {"ok": True, "status": "reachable"}
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
response = self._rows(resource)
fields = kwargs.get("fields")
response["data"] = [project_row(row, fields) for row in response["data"]]
return response
def _rows(self, resource):
if resource == "users":
return {
"data": [
{
"id": 54,
"username": "alice",
"nickname": "Alice Wang",
"email": "alice@example.com",
"f_yh_english_name": "Alice Wang",
}
],
"meta": {"count": 1},
}
if resource == "contacts":
return {
"data": [
{
"id": 2,
"name": "Alice Contact",
"f_2zpg91gtmwp": "Alice Contact",
"phone": "+1 555 0100",
"khlxr_phone_number": "+1 555 0100",
"email": "alice.contact@example.com",
"khlxr_email": "alice.contact@example.com",
"title": "Buyer",
"f_tn8zdm2f938": "Buyer",
"customer": [{"id": 1, "name": "Acme"}],
"kehulianxiren_client": [{"id": 1, "f_q2q60q7bqbg": "Acme China"}],
"createdAt": "2026-07-01T00:00:00.000Z",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
],
"meta": {"count": 1},
}
if resource == "followups":
# Carries both the default-config key names and the production field
# codes so the same fixture serves tests running under either config.
return {
"data": [
{
"id": 1,
"followedAt": "2026-07-08T09:00:00.000Z",
"f_srysmjkwfq1": "2026-07-08T09:00:00.000Z",
"content": "Called customer about sample status and confirmed next procurement meeting.",
"f_19iplxz0nds": "Called customer about sample status and confirmed next procurement meeting.",
"customer": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
"gjjl_client": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}],
"contact": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
"gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}],
"owner": [{"id": 54, "nickname": "Alice Wang"}],
"gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"createdBy": {"id": 54, "nickname": "Alice Wang"},
"createdAt": "2026-07-08T09:10:00.000Z",
"updatedAt": "2026-07-08T09:20:00.000Z",
}
],
"meta": {"count": 1},
}
if resource == "customers":
return {
"data": [
{
"id": 1,
"name": "Acme",
"f_q2q60q7bqbg": "Acme China",
"f_kh_english_name": "Acme",
"f_territorial_name": "US",
"status": "kh_p_inprogress",
"kh_progress": "kh_p_inprogress",
"source": "客户推荐",
"f_225h6n6v654": "客户推荐",
"owner": [{"id": 54, "nickname": "Alice Wang"}],
"kehu_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"kehu_contacts": [{"id": 99, "name": "Nested contact that should stay out of compact results"}],
"createdAt": "2026-07-01T00:00:00.000Z",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
],
"meta": {"count": 1},
}
return {"data": [{"id": 1, "name": "Acme"}], "meta": {"count": 1}}
def get_record(self, resource, filter_by_tk, **kwargs):
self.get_calls.append((resource, filter_by_tk, kwargs))
row = {
"id": filter_by_tk,
"name": "Acme",
"f_q2q60q7bqbg": "Acme China",
"f_kh_english_name": "Acme",
"kh_progress": "kh_p_inprogress",
"f_y8iyf2jhana": "88 Nanjing Road",
"f_spgoie0arjq": "Connector supplier.",
"kehu_followup_person": [{"id": 54, "nickname": "Alice Wang"}],
"khlxr_phone_number": "+1 555 0100",
"khlxr_email": "alice.contact@example.com",
}
return {"data": project_row(row, kwargs.get("fields"))}
def query_records(self, resource, **kwargs):
self.query_calls.append((resource, kwargs))
dimensions = kwargs.get("dimensions") or []
alias = dimensions[0].get("alias") if dimensions else None
if alias in {"status", "kh_progress"}:
return {
"data": [
{"count": 3, "kh_progress": "kh_p_inprogress"},
{"count": 9, "kh_progress": "kh_p_ordered"},
]
}
if alias in {"source", "f_225h6n6v654"}:
return {
"data": [
{"count": 1, "f_225h6n6v654": "https://example.com"},
{"count": 9, "f_225h6n6v654": "客户推荐"},
{"count": 2, "f_225h6n6v654": ""},
]
}
if alias == "owner_id":
return {"data": [{"count": 1, "owner_id": 54}]}
if alias == "kehu_followup_person_id":
return {"data": [{"count": 1, "kehu_followup_person_id": 54}]}
return {"data": [{"count": 3}]}
def list_collection_fields(self, collection_name, **kwargs):
self.field_calls.append((collection_name, kwargs))
if collection_name == "contacts":
return {
"data": [
{"name": "id", "title": "ID", "type": "bigInt"},
{"name": "f_2zpg91gtmwp", "title": "联系人姓名", "type": "string"},
{"name": "khlxr_phone_number", "title": "联系人电话", "type": "string"},
{"name": "khlxr_email", "title": "联系人邮箱", "type": "string"},
{"name": "kehulianxiren_client", "title": "公司", "type": "belongsToMany"},
]
}
if collection_name == "followups":
return {
"data": [
{"name": "id", "title": "跟进ID", "type": "bigInt"},
{"name": "gjjl_client", "title": "客户", "type": "belongsToMany"},
{"name": "gjjl_client_contacts", "title": "客户联系人", "type": "belongsToMany"},
{"name": "gjjl_followup_person", "title": "参与跟进人员", "type": "belongsToMany"},
{"name": "createdBy", "title": '{{t("Created by")}}', "type": "belongsTo"},
{"name": "f_srysmjkwfq1", "title": "跟进时间", "type": "date"},
{"name": "f_19iplxz0nds", "title": "跟进内容", "type": "text"},
]
}
return {
"data": [
{"name": "id", "title": "ID", "type": "bigInt"},
{"name": "f_q2q60q7bqbg", "title": "中文名称", "type": "string"},
{"name": "f_kh_english_name", "title": "英文名称", "type": "string"},
{"name": "f_territorial_name", "title": "属地名称", "type": "string"},
{"name": "kh_progress", "title": "进度", "type": "string"},
{"name": "f_225h6n6v654", "title": "客户来源", "type": "string"},
{"name": "kehu_followup_person", "title": "跟进人", "type": "belongsToMany"},
]
}
class CrmServiceTests(unittest.TestCase):
def test_crm_health_probes_the_configured_customer_resource(self) -> None:
client = FakeNbClient()
result = CrmService(load_config(None), client).crm_health()
self.assertTrue(result["ok"])
self.assertEqual(client.health_calls, [("customers", "id")])
def test_list_customers_applies_configured_fields_and_filters(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.list_customers(query="Acme", status="active", owner="Alice", limit=10, offset=20)
self.assertEqual(result["results"][0]["id"], 1)
self.assertEqual(result["results"][0]["name"], "Acme")
self.assertEqual(result["results"][0]["status"], "进行中")
self.assertEqual(result["results"][0]["owners"], [{"id": 54, "name": "Alice Wang"}])
self.assertNotIn("kehu_contacts", result["results"][0])
self.assertNotIn("raw", result["results"][0])
self.assertEqual(result["total"], 1)
self.assertEqual(client.list_calls[0][0], "users")
resource, kwargs = client.list_calls[1]
self.assertEqual(resource, "customers")
# The owner relation is fetched as a dotted projection, never bare -- a bare
# relation makes NocoBase return the whole related row.
self.assertIn("owner.id", kwargs["fields"])
self.assertIn("owner.nickname", kwargs["fields"])
self.assertNotIn("owner", kwargs["fields"])
self.assertEqual(kwargs["page_size"], 10)
self.assertEqual(kwargs["page"], 3)
self.assertEqual(
kwargs["filter"],
{
"$and": [
{"status": {"$eq": "active"}},
{"owner": {"id": {"$eq": 54}}},
{
"$or": [
{"name": {"$includes": "Acme"}},
{"code": {"$includes": "Acme"}},
]
},
]
},
)
def test_get_customer_loads_related_contacts_and_followups(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.get_customer(7, include_contacts=True, include_recent_followups=True, followup_limit=5)
self.assertEqual(result["customer"]["id"], 7)
self.assertEqual(result["customer"]["name"], "Acme")
self.assertEqual(result["timezone"], "Asia/Shanghai")
# The customer is compacted, not handed back as the raw NocoBase row.
self.assertNotIn("f_q2q60q7bqbg", result["customer"])
self.assertNotIn("raw", result["customer"])
self.assertEqual(result["contacts"][0]["id"], 2)
self.assertEqual(result["contacts"][0]["customers"], [{"id": 1, "name": "Acme"}])
# Personal contact details do not belong in a customer overview.
self.assertNotIn("phone", result["contacts"][0])
self.assertNotIn("email", result["contacts"][0])
self.assertEqual(
result["contacts_pagination"],
{"total": 1, "returned": 1, "limit": 50, "offset": 0, "has_more": False, "next_offset": None},
)
self.assertEqual(result["recent_followups"][0]["id"], 1)
self.assertEqual(result["recent_followups_pagination"]["limit"], 5)
self.assertFalse(result["recent_followups_pagination"]["has_more"])
self.assertEqual(
result["recent_followups"][0]["content"],
"Called customer about sample status and confirmed next procurement meeting.",
)
self.assertNotIn("f_19iplxz0nds", result["recent_followups"][0])
self.assertEqual(client.get_calls[0][0], "customers")
self.assertEqual(client.list_calls[0][0], "contacts")
self.assertEqual(client.list_calls[0][1]["filter"], {"$and": [{"customer": {"id": {"$eq": 7}}}]})
self.assertEqual(client.list_calls[1][0], "followups")
self.assertEqual(client.list_calls[1][1]["page_size"], 5)
def test_crm_overview_uses_count_and_group_queries(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.crm_overview(owner="Alice")
self.assertEqual(result["totals"]["customers"], 3)
self.assertEqual(result["totals"]["contacts"], 3)
self.assertEqual(result["totals"]["followups"], 3)
self.assertEqual(result["customer_status"][0]["label"], "已下单")
self.assertEqual(
result["recent_followups"][0]["content"],
"Called customer about sample status and confirmed next procurement meeting.",
)
self.assertEqual(result["customer_owner"][0]["owner_id"], 54)
self.assertGreaterEqual(len(client.query_calls), 3)
self.assertEqual(client.query_calls[0][0], "customers")
self.assertEqual(client.query_calls[0][1]["measures"][0]["aggregation"], "count")
def test_crm_schema_include_fields_reads_each_configured_collection_fields(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
schema = service.crm_schema(include_fields=True)
self.assertEqual(
[call[0] for call in client.field_calls],
["customers", "contacts", "followups", "t_team_management", "t_projects", "t_qxeepjvi39s"],
)
self.assertEqual(client.field_calls[0][1].get("fields"), None)
self.assertEqual(schema["metadata"]["fields"]["customers"][1]["name"], "f_q2q60q7bqbg")
self.assertEqual(schema["metadata"]["field_errors"], {})
def test_crm_schema_include_fields_reports_metadata_acl_hint(self) -> None:
class ForbiddenFieldsClient(FakeNbClient):
def list_collection_fields(self, collection_name, **kwargs):
raise Exception("Request failed with status 403: No permissions")
service = CrmService(load_config(None), ForbiddenFieldsClient())
schema = service.crm_schema(include_fields=True)
self.assertIn("metadata_permission_required", schema["metadata"]["field_errors"]["customers"]["code"])
self.assertIn("data-modeling collections fields list", schema["metadata"]["field_errors"]["customers"]["hint"])
def test_crm_config_template_infers_mapping_from_metadata(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
template = service.crm_config_template()
self.assertEqual(
template["collections"]["customers"]["searchFields"],
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
)
self.assertEqual(template["collections"]["customers"]["statusField"], "kh_progress")
self.assertEqual(template["collections"]["customers"]["sourceField"], "f_225h6n6v654")
self.assertEqual(template["collections"]["customers"]["ownerField"], "kehu_followup_person")
self.assertEqual(template["collections"]["contacts"]["customerField"], "kehulianxiren_client")
self.assertEqual(template["collections"]["followups"]["customerField"], "gjjl_client")
self.assertEqual(template["collections"]["followups"]["contactField"], "gjjl_client_contacts")
self.assertEqual(template["collections"]["followups"]["ownerFields"], ["gjjl_followup_person", "createdBy"])
self.assertEqual(template["collections"]["followups"]["dateField"], "f_srysmjkwfq1")
def test_list_customers_resolves_owner_name_to_relation_id_filter(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
service.list_customers(owner="alice")
self.assertEqual(client.list_calls[0][0], "users")
self.assertEqual(
client.list_calls[1][1]["filter"],
{"$and": [{"owner": {"id": {"$eq": 54}}}]},
)
def test_list_followups_filters_owner_by_participant_only(self) -> None:
client = FakeNbClient()
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"collections": {
"followups": {
"ownerField": "gjjl_followup_person",
"ownerFields": ["gjjl_followup_person", "createdBy"],
}
}
}
),
encoding="utf-8",
)
service = CrmService(load_config(config_path), client)
service.list_followups(owner="alice")
self.assertEqual(client.list_calls[0][0], "users")
self.assertEqual(
client.list_calls[1][1]["filter"],
{"$and": [{"gjjl_followup_person": {"id": {"$eq": 54}}}]},
)
def test_customer_without_a_chinese_name_shows_its_english_name_not_its_id(self) -> None:
"""Regression: customer 4249 surfaced as the string "4249".
Its Chinese-name field was empty and its real name lived in
f_kh_english_name, which was missing from the name candidates, so label
resolution fell all the way through to the id.
"""
class ZomatoClient(FakeNbClient):
def _rows(self, resource):
if resource == "followups":
return {
"data": [
{
"id": 1,
"followedAt": "2026-07-08T09:00:00.000Z",
"content": "Dinner with the procurement team.",
"customer": [
{
"id": 4249,
"f_q2q60q7bqbg": None,
"f_kh_english_name": "Zomato(Eternal)",
}
],
}
],
"meta": {"count": 1},
}
return super()._rows(resource)
row = CrmService(load_config(None), ZomatoClient()).list_followups(limit=1)["results"][0]
self.assertEqual(row["customers"], [{"id": 4249, "name": "Zomato(Eternal)"}])
def test_customer_with_no_name_at_all_is_marked_as_an_id_not_passed_off_as_a_name(self) -> None:
class NamelessClient(FakeNbClient):
def _rows(self, resource):
if resource == "followups":
return {
"data": [
{
"id": 1,
"followedAt": "2026-07-08T09:00:00.000Z",
"customer": [{"id": 4249}],
}
],
"meta": {"count": 1},
}
return super()._rows(resource)
row = CrmService(load_config(None), NamelessClient()).list_followups(limit=1)["results"][0]
self.assertEqual(row["customers"], [{"id": 4249, "name": "#4249"}])
self.assertNotEqual(row["customers"][0]["name"], "4249")
def test_get_customer_renames_detail_fields_and_leaks_no_field_codes(self) -> None:
"""The whole point of the rewrite: readable keys, no NocoBase codes."""
client = FakeNbClient()
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "crm.json"
config_path.write_text(
json.dumps(
{
"collections": {
"customers": {
"fields": [
"id",
"f_q2q60q7bqbg",
"f_kh_english_name",
"kh_progress",
"f_y8iyf2jhana",
"f_spgoie0arjq",
"kehu_followup_person",
],
"nameFields": ["f_q2q60q7bqbg", "f_kh_english_name"],
"statusField": "kh_progress",
"ownerField": "kehu_followup_person",
"detailFields": {"address": "f_y8iyf2jhana", "intro": "f_spgoie0arjq"},
}
}
}
),
encoding="utf-8",
)
service = CrmService(load_config(config_path), client)
result = service.get_customer(7, include_contacts=False, include_recent_followups=False)
self.assertEqual(result["customer"]["name"], "Acme China")
self.assertEqual(result["customer"]["address"], "88 Nanjing Road")
self.assertEqual(result["customer"]["intro"], "Connector supplier.")
self.assertEqual(result["customer"]["status"], "进行中")
self.assertEqual(result["customer"]["owners"], [{"id": 54, "name": "Alice Wang"}])
def field_codes(value):
if isinstance(value, dict):
for key, nested in value.items():
if re.fullmatch(r"f_[a-z0-9_]+", str(key)):
yield key
yield from field_codes(nested)
elif isinstance(value, list):
for item in value:
yield from field_codes(item)
self.assertEqual(list(field_codes(result)), [])
def test_get_customer_never_returns_internal_nocobase_field_codes(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).get_customer(7)
def field_codes(value):
if isinstance(value, dict):
for key, nested in value.items():
if re.fullmatch(r"f_[a-z0-9_]+", str(key)):
yield key
yield from field_codes(nested)
elif isinstance(value, list):
for item in value:
yield from field_codes(item)
self.assertEqual(list(field_codes(result)), [])
def test_get_customer_reports_a_structured_error_for_an_unknown_id(self) -> None:
class MissingClient(FakeNbClient):
def get_record(self, resource, filter_by_tk, **kwargs):
self.get_calls.append((resource, filter_by_tk, kwargs))
return {"data": None}
result = CrmService(load_config(None), MissingClient()).get_customer(999)
self.assertEqual(result["error"]["code"], "customer_not_found")
def test_requested_fields_never_ask_for_a_relation_both_bare_and_projected(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
service.list_customers()
service.list_contacts()
service.list_followups()
service.list_stale_customers()
for resource, kwargs in client.list_calls:
fields = kwargs.get("fields") or []
roots = {path.split(".", 1)[0] for path in fields if "." in path}
overlap = roots.intersection(fields)
self.assertEqual(overlap, set(), f"{resource} requested {overlap} bare and projected")
def test_projections_always_lead_with_the_id_field(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
service.list_followups()
fields = client.list_calls[0][1]["fields"]
for root in {path.split(".", 1)[0] for path in fields if "." in path}:
projected = [path for path in fields if path.startswith(f"{root}.")]
self.assertEqual(projected[0], f"{root}.id")
def test_stale_customer_scan_projects_the_customer_relation_down_to_its_id(self) -> None:
client = FakeNbClient()
CrmService(load_config(None), client).list_stale_customers()
followup_call = next(kwargs for resource, kwargs in client.list_calls if resource == "followups")
self.assertIn("customer.id", followup_call["fields"])
self.assertNotIn("customer", followup_call["fields"])
def test_rejected_projection_falls_back_to_relation_roots_with_a_warning(self) -> None:
class PickyClient(FakeNbClient):
def list_records(self, resource, **kwargs):
if resource == "followups" and any("." in path for path in kwargs.get("fields") or []):
raise NbCommandError("Unknown field gjjl_client.f_bogus")
return super().list_records(resource, **kwargs)
result = CrmService(load_config(None), PickyClient()).list_followups(limit=1)
self.assertEqual(result["results"][0]["id"], 1)
self.assertIn(PROJECTION_FALLBACK_WARNING, result["data_quality_warnings"])
def test_empty_values_are_omitted_but_ids_are_always_kept(self) -> None:
self.assertEqual(_prune({"id": None, "name": "", "tags": [], "status": "OK"}), {"id": None, "status": "OK"})
def test_owner_resolution_returns_structured_errors_for_no_or_ambiguous_matches(self) -> None:
class NoUserClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {"data": [], "meta": {"count": 0}}
return super().list_records(resource, **kwargs)
missing = CrmService(load_config(None), NoUserClient()).list_customers(owner="missing")
self.assertEqual(missing["error"]["code"], "owner_not_found")
self.assertEqual(missing["error"]["query"], "missing")
class AmbiguousUserClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "users":
return {
"data": [
{"id": 1, "username": "alice", "nickname": "Alice"},
{"id": 2, "username": "alice2", "nickname": "Alice"},
],
"meta": {"count": 2},
}
return super().list_records(resource, **kwargs)
ambiguous = CrmService(load_config(None), AmbiguousUserClient()).list_customers(owner="alice")
self.assertEqual(ambiguous["error"]["code"], "owner_ambiguous")
self.assertEqual(len(ambiguous["error"]["candidates"]), 2)
def test_resolve_owner_returns_candidate_users(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).resolve_owner("alice")
self.assertEqual(result["query"], "alice")
self.assertEqual(result["results"][0]["id"], 54)
self.assertEqual(result["results"][0]["label"], "Alice Wang")
def test_crm_filter_options_exposes_filter_values_and_owner_candidates(self) -> None:
client = FakeNbClient()
result = CrmService(load_config(None), client).crm_filter_options(owner_query="alice")
self.assertEqual(result["customer_status"][0]["value"], "kh_p_ordered")
self.assertEqual(result["customer_status"][0]["label"], "已下单")
self.assertEqual(result["customer_source"][0]["value"], "客户推荐")
self.assertEqual(result["customer_source"][-1]["value"], "")
self.assertEqual(result["owner_candidates"][0]["id"], 54)
def test_list_customers_can_opt_into_raw_rows(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_customers(limit=1, include_raw=True)
row = result["results"][0]
self.assertEqual(row["name"], "Acme")
# raw keeps the relation expanded the way NocoBase returned it.
self.assertEqual(row["raw"]["owner"][0]["nickname"], "Alice Wang")
def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None:
client = FakeNbClient()
service = CrmService(load_config(None), client)
result = service.list_customers(limit=10000, offset=-50)
self.assertEqual(result["limit"], 100)
self.assertEqual(result["offset"], 0)
self.assertEqual(client.list_calls[0][1]["page_size"], 100)
self.assertEqual(client.list_calls[0][1]["page"], 1)
def test_list_followups_returns_compact_preview_by_default(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1)
row = result["results"][0]
# Pinning the exact key set is the regression guard against the row
# quietly growing duplicate people/status/timestamp fields again.
self.assertEqual(
set(row),
{"id", "followedAt", "customers", "contacts", "participants", "content"},
)
self.assertEqual(row["id"], 1)
self.assertEqual(row["followedAt"], "2026-07-08T17:00:00+08:00")
self.assertEqual(row["customers"], [{"id": 7, "name": "Acme China"}])
self.assertEqual(row["contacts"], [{"id": 8, "name": "Alice Contact"}])
self.assertEqual(row["participants"], [{"id": 54, "name": "Alice Wang"}])
self.assertEqual(
row["content"],
"Called customer about sample status and confirmed next procurement meeting.",
)
# The submitter is also a participant, so naming them twice adds nothing.
self.assertNotIn("creator", row)
self.assertNotIn("f_19iplxz0nds", row)
def test_list_followups_can_opt_into_raw_rows(self) -> None:
result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1, include_raw=True)
row = result["results"][0]
self.assertEqual(
row["content"],
"Called customer about sample status and confirmed next procurement meeting.",
)
self.assertEqual(row["raw"]["customer"][0]["f_q2q60q7bqbg"], "Acme China")
def test_arbitrary_offset_fetches_two_pages_and_slices_exactly(self) -> None:
class PagedClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource != "customers":
return super().list_records(resource, **kwargs)
page = kwargs["page"]
page_size = kwargs["page_size"]
start = (page - 1) * page_size
return {
"data": [
{"id": index, "name": f"Customer {index}"}
for index in range(start + 1, min(start + page_size, 50) + 1)
],
"meta": {"count": 50},
}
client = PagedClient()
result = CrmService(load_config(None), client).list_customers(limit=10, offset=15)
self.assertEqual([row["id"] for row in result["results"]], list(range(16, 26)))
customer_calls = [kwargs for resource, kwargs in client.list_calls if resource == "customers"]
self.assertEqual([call["page"] for call in customer_calls], [2, 3])
self.assertEqual(result["next_offset"], 25)
def test_team_filter_uses_formal_team_members_and_customer_owners(self) -> None:
class TeamClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "t_team_management":
return {
"data": [
{
"id": 9,
"f_team_name": "华东团队",
"tdgl_manager": [{"id": 54, "nickname": "Alice Wang"}],
"tdgl_team_members": [{"id": 54, "nickname": "Alice Wang"}],
}
],
"meta": {"count": 1},
}
if resource == "users":
return {
"data": [
{
"id": 54,
"username": "alice",
"nickname": "Alice Wang",
"yh_status": "nkluuumb1r1",
}
],
"meta": {"count": 1},
}
return super().list_records(resource, **kwargs)
client = TeamClient()
service = CrmService(load_config(None), client)
resolved = service.resolve_team("华东团队")
result = service.list_customers(team="华东团队")
self.assertEqual(resolved["results"][0]["name"], "华东团队")
customer_call = next(kwargs for resource, kwargs in reversed(client.list_calls) if resource == "customers")
self.assertEqual(
customer_call["filter"],
{"$and": [{"owner": {"id": {"$in": [54]}}}]},
)
self.assertEqual(result["results"][0]["owners"], [{"id": 54, "name": "Alice Wang"}])
def test_team_user_resolution_pages_past_one_hundred_members(self) -> None:
class PagedUsersClient(FakeNbClient):
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource != "users":
return super().list_records(resource, **kwargs)
page = kwargs["page"]
page_size = kwargs["page_size"]
start = (page - 1) * page_size
rows = [
{"id": index, "nickname": f"User {index}", "yh_status": "nkluuumb1r1"}
for index in range(start + 1, min(start + page_size, 150) + 1)
]
return {"data": rows, "meta": {"count": 150}}
client = PagedUsersClient()
service = CrmService(load_config(None), client)
team_row = {"tdgl_team_members": [{"id": index} for index in range(1, 151)]}
users = service._team_users(team_row, "tdgl_team_members", formal_only=True)
self.assertEqual(len(users), 150)
user_calls = [kwargs for resource, kwargs in client.list_calls if resource == "users"]
self.assertEqual([call["page"] for call in user_calls], [1, 2])
def test_project_query_and_timeline_use_crm_project_relations(self) -> None:
class ProjectClient(FakeNbClient):
project = {
"id": 41,
"f_project_name": "EV inverter",
"client_ID": 7,
"f_projects_client": {"id": 7, "f_q2q60q7bqbg": "Acme China"},
"f_projects_progress": "k4w2vcz819w",
"createdAt": "2026-07-01T00:00:00.000Z",
"updatedAt": "2026-07-08T00:00:00.000Z",
}
def list_records(self, resource, **kwargs):
self.list_calls.append((resource, kwargs))
if resource == "t_projects":
return {"data": [dict(self.project)], "meta": {"count": 1}}
if resource == "t_qxeepjvi39s":
return {
"data": [
{
"id": 5,
"f_6g9fw5oqef9": "2026-07-09T00:00:00.000Z",
"f_ei16g0ihwwd": "报价已提交",
"createdBy": {"id": 54, "nickname": "Alice Wang"},
}
],
"meta": {"count": 1},
}
return super().list_records(resource, **kwargs)
def get_record(self, resource, filter_by_tk, **kwargs):
self.get_calls.append((resource, filter_by_tk, kwargs))
if resource == "t_projects":
return {"data": dict(self.project)}
return super().get_record(resource, filter_by_tk, **kwargs)
client = ProjectClient()
service = CrmService(load_config(None), client)
listed = service.list_projects(stage="已报价")
detail = service.get_project(41)
timeline = service.get_project_timeline(41)
self.assertEqual(listed["results"][0]["stage"], "已报价")
self.assertEqual(listed["results"][0]["customers"], [{"id": 7, "name": "Acme China"}])
# Relations are projected, so the CLI is never asked to append whole rows.
project_call = next(kwargs for resource, kwargs in client.list_calls if resource == "t_projects")
self.assertNotIn("appends", project_call)
self.assertIn("f_projects_client.f_q2q60q7bqbg", project_call["fields"])
self.assertEqual(detail["followups_pagination"]["total"], 1)
self.assertFalse(detail["followups_pagination"]["has_more"])
self.assertEqual(
{event["event_type"] for event in timeline["events"]},
{"followup", "project_update"},
)
followup_event = next(e for e in timeline["events"] if e["event_type"] == "followup")
self.assertEqual(followup_event["actors"], ["Alice Wang"])
update_event = next(e for e in timeline["events"] if e["event_type"] == "project_update")
self.assertEqual(update_event["actors"], ["Alice Wang"])
def test_stale_customers_uses_last_valid_followup_date(self) -> None:
class StaleClient(FakeNbClient):
def list_records(self, resource, **kwargs):
response = super().list_records(resource, **kwargs)
if resource == "customers":
response["data"][0]["id"] = 7
return response
result = CrmService(load_config(None), StaleClient()).list_stale_customers(
inactive_days=30,
as_of="2026-08-10",
)
self.assertEqual(result["results"][0]["customer"]["id"], 7)
self.assertEqual(result["results"][0]["days_since_last_followup"], 33)
def test_scan_limit_returns_explicit_continuation_metadata(self) -> None:
class ThreeRowClient(FakeNbClient):
def list_records(self, resource, **kwargs):
page = kwargs["page"]
page_size = kwargs["page_size"]
start = (page - 1) * page_size
return {
"data": [{"id": index} for index in range(start + 1, min(start + page_size, 3) + 1)],
"meta": {"count": 3},
}
rows, truncation = CrmService(load_config(None), ThreeRowClient())._scan_records(
"customers",
page_size=2,
max_records=2,
)
self.assertEqual([row["id"] for row in rows], [1, 2])
self.assertEqual(
truncation,
{"total": 3, "returned": 2, "limit": 2, "next_offset": 2},
)
if __name__ == "__main__":
unittest.main()
-488
View File
@@ -1,488 +0,0 @@
import os
import stat
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import call, patch
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import manage_mcp_service
import mcp_service_runtime
class McpServiceConfigTests(unittest.TestCase):
def test_service_management_has_one_shell_entrypoint(self) -> None:
entrypoint = SCRIPTS_DIR / "mcp_service.sh"
self.assertTrue(entrypoint.exists())
content = entrypoint.read_text(encoding="utf-8")
self.assertIn("manage_mcp_service.py", content)
self.assertIn('exec python3 "$PROJECT_ROOT/scripts/manage_mcp_service.py"', content)
self.assertFalse((SCRIPTS_DIR / "new_mcp_service_token.sh").exists())
self.assertFalse((SCRIPTS_DIR / "start_mcp_service.sh").exists())
self.assertFalse((SCRIPTS_DIR / "show_mcp_service_config.sh").exists())
self.assertFalse((SCRIPTS_DIR / "stop_mcp_service.sh").exists())
self.assertFalse((SCRIPTS_DIR / "uninstall_mcp_service.sh").exists())
def test_missing_env_message_points_to_start_and_token_rotation(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / ".runtime" / "nocobase-crm-mcp.env"
with self.assertRaisesRegex(RuntimeError, r"scripts/mcp_service\.sh start"):
manage_mcp_service.read_service_env_file(env_path)
def test_render_service_unit_uses_http_defaults_without_embedding_token(self) -> None:
unit = manage_mcp_service.render_service_unit(
project_root=Path("/opt/nocobase-mcp"),
env_file=Path("/opt/nocobase-mcp/.runtime/nocobase-crm-mcp.env"),
uv_path=Path("/home/nac/.local/bin/uv"),
)
self.assertIn("WorkingDirectory=/opt/nocobase-mcp", unit)
self.assertIn("EnvironmentFile=/opt/nocobase-mcp/.runtime/nocobase-crm-mcp.env", unit)
self.assertIn("/home/nac/.local/bin/uv run --no-sync nocobase-crm-mcp", unit)
self.assertIn("--transport streamable-http", unit)
self.assertIn("--host 127.0.0.1", unit)
self.assertIn("--port 10087", unit)
self.assertNotIn("NOCOBASE_MCP_TOKEN=", unit)
def test_write_token_env_file_is_private_and_contains_runtime_settings(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / ".runtime" / "nocobase-crm-mcp.env"
manage_mcp_service.write_token_env_file(
env_path,
"test-token",
host="0.0.0.0",
port=10087,
path="/mcp",
nb_env="nb-mcp",
config_path="config.json",
nb_bin="/opt/node/bin/nb",
)
content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=test-token\n", content)
self.assertIn("NOCOBASE_MCP_HOST=0.0.0.0\n", content)
self.assertIn("NOCOBASE_MCP_PORT=10087\n", content)
self.assertIn("NOCOBASE_MCP_PATH=/mcp\n", content)
self.assertIn("NOCOBASE_MCP_ENV=nb-mcp\n", content)
self.assertIn("NOCOBASE_MCP_CONFIG=config.json\n", content)
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", content)
self.assertIn("NOCOBASE_MCP_ENABLE_UPLOADS=0\n", content)
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_find_nb_path_uses_env_or_path_and_errors_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
nb_bin = Path(tmp) / "bin" / "nb"
nb_bin.parent.mkdir()
nb_bin.write_text("#!/bin/sh\n", encoding="utf-8")
nb_bin.chmod(0o755)
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": str(nb_bin)}):
self.assertEqual(manage_mcp_service.find_nb_path(), Path(os.path.abspath(nb_bin)))
cli_script = Path(tmp) / "lib" / "node_modules" / "@nocobase" / "cli" / "bin" / "run.js"
cli_script.parent.mkdir(parents=True)
cli_script.write_text("#!/usr/bin/env node\n", encoding="utf-8")
cli_script.chmod(0o755)
nb_link = Path(tmp) / "node" / "bin" / "nb"
nb_link.parent.mkdir(parents=True)
nb_link.symlink_to(cli_script)
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": str(nb_link)}):
self.assertEqual(manage_mcp_service.find_nb_path(), nb_link)
missing_nb = Path(tmp) / "missing" / "nb"
with patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": str(missing_nb)}):
with self.assertRaisesRegex(RuntimeError, "not an executable"):
manage_mcp_service.find_nb_path()
with (
patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "nb-custom"}),
patch.object(
mcp_service_runtime.shutil,
"which",
return_value="/usr/local/bin/nb-custom",
),
):
self.assertEqual(manage_mcp_service.find_nb_path(), Path("/usr/local/bin/nb-custom"))
with (
patch.dict("os.environ", {}, clear=True),
patch.object(mcp_service_runtime.shutil, "which", return_value="/usr/local/bin/nb"),
):
self.assertEqual(manage_mcp_service.find_nb_path(), Path("/usr/local/bin/nb"))
with (
patch.dict("os.environ", {}, clear=True),
patch.object(mcp_service_runtime.shutil, "which", return_value=None),
):
with self.assertRaisesRegex(RuntimeError, "Could not find nb"):
manage_mcp_service.find_nb_path()
def test_generated_env_has_size_but_no_nocobase_api_credentials(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.assertIn("NOCOBASE_MCP_ENABLE_UPLOADS=0\n", content)
self.assertNotIn("NOCOBASE_MCP_API_TOKEN", content)
self.assertNotIn("NOCOBASE_MCP_API_BASE_URL", content)
self.assertNotIn("NOCOBASE_MCP_UPLOAD_JWT", content)
self.assertNotIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL", content)
def test_saved_service_config_returns_named_typed_values(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp)
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"token",
host="127.0.0.1",
port=10088,
path="/custom-mcp",
nb_env="personal-env",
config_path="custom.json",
nb_bin="/opt/bin/nb",
)
config = manage_mcp_service.saved_service_config(project_root)
self.assertIsInstance(config, manage_mcp_service.ServiceConfig)
self.assertEqual(config.token, "token")
self.assertEqual(config.port, 10088)
self.assertEqual(config.path, "/custom-mcp")
self.assertEqual(config.nb_env, "personal-env")
self.assertEqual(config.config_path, "custom.json")
self.assertEqual(config.nb_bin, "/opt/bin/nb")
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_API_BASE_URL=https://nocobase.example/api\n"
"NOCOBASE_MCP_API_TOKEN=operator-api-key\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_API_BASE_URL=https://nocobase.example/api\n", content)
self.assertIn("NOCOBASE_MCP_API_TOKEN=operator-api-key\n", content)
def test_token_rotation_preserves_explicit_upload_opt_in(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / "mcp.env"
manage_mcp_service.write_token_env_file(env_path, "old-token")
content = env_path.read_text(encoding="utf-8").replace(
"NOCOBASE_MCP_ENABLE_UPLOADS=0",
"NOCOBASE_MCP_ENABLE_UPLOADS=1",
)
env_path.write_text(content, encoding="utf-8")
manage_mcp_service.write_token_env_file(env_path, "new-token")
content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_ENABLE_UPLOADS=1\n", content)
self.assertEqual(content.count("NOCOBASE_MCP_ENABLE_UPLOADS="), 1)
def test_render_http_config_prints_url_and_authorization_header(self) -> None:
output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10"],
token="test-token",
port=10087,
path="/mcp",
service_status="inactive",
)
self.assertIn("http://192.168.1.10:10087/mcp", output)
self.assertIn("Authorization: Bearer test-token", output)
self.assertIn('"nocobase-crm"', output)
self.assertIn('"type": "streamable-http"', output)
self.assertIn("Service status: inactive", output)
self.assertNotIn("MCP service is running.", output)
self.assertIn("Use this loopback URL on this machine", output)
def test_new_token_writes_token_without_touching_systemd_when_service_is_not_installed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(mcp_service_runtime, "generate_token", return_value="new-token"),
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl,
):
output = manage_mcp_service.new_token(project_root=project_root)
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
env_content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", env_content)
self.assertIn("NOCOBASE_MCP_PORT=10087\n", env_content)
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", env_content)
self.assertIn("http://127.0.0.1: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(mcp_service_runtime, "generate_token", return_value="new-token"),
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]),
patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(mcp_service_runtime, "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"
project_root.mkdir()
config_home = Path(tmp) / "config"
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
host="0.0.0.0",
port=10087,
path="/mcp",
nb_env="nb-mcp",
config_path="config.json",
)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl,
patch.object(
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
) as probe_http_service,
):
output = manage_mcp_service.start_service(project_root=project_root)
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
unit_content = unit_path.read_text(encoding="utf-8")
env_content = (project_root / ".runtime" / "nocobase-crm-mcp.env").read_text(encoding="utf-8")
self.assertIn("/home/nac/.local/bin/uv run --no-sync nocobase-crm-mcp", unit_content)
self.assertIn("EnvironmentFile=", unit_content)
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", env_content)
self.assertIn("http://127.0.0.1:10087/mcp", output)
self.assertIn("Bearer saved-token", output)
run_systemctl.assert_has_calls(
[
call(["daemon-reload"]),
call(["enable", manage_mcp_service.SERVICE_NAME]),
call(["restart", manage_mcp_service.SERVICE_NAME]),
call(["is-active", "--quiet", manage_mcp_service.SERVICE_NAME]),
]
)
probe_http_service.assert_called_once_with("127.0.0.1", 10087, "/mcp", token="saved-token")
def test_start_service_generates_token_on_first_run(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(mcp_service_runtime, "generate_token", return_value="first-token"),
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(mcp_service_runtime, "run_systemctl") as run_systemctl,
patch.object(
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
) as probe_http_service,
):
output = manage_mcp_service.start_service(project_root=project_root)
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
self.assertIn("NOCOBASE_MCP_TOKEN=first-token\n", env_path.read_text(encoding="utf-8"))
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", env_path.read_text(encoding="utf-8"))
self.assertIn("Bearer first-token", output)
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
self.assertTrue(unit_path.exists())
run_systemctl.assert_has_calls(
[
call(["daemon-reload"]),
call(["enable", manage_mcp_service.SERVICE_NAME]),
call(["restart", manage_mcp_service.SERVICE_NAME]),
call(["is-active", "--quiet", manage_mcp_service.SERVICE_NAME]),
]
)
probe_http_service.assert_called_once_with("127.0.0.1", 10087, "/mcp", token="first-token")
def test_start_service_waits_for_http_probe_after_systemd_is_active(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(mcp_service_runtime, "run_systemctl"),
patch.object(mcp_service_runtime.time, "sleep") as sleep,
patch.object(
mcp_service_runtime,
"probe_http_service",
side_effect=[
{"reachable": False, "error": "connection refused"},
{"reachable": True, "status": 406},
],
create=True,
) as probe_http_service,
):
output = manage_mcp_service.start_service(project_root=project_root)
self.assertIn("Service status: active", output)
self.assertEqual(probe_http_service.call_count, 2)
sleep.assert_called_once()
def test_start_service_fails_when_systemd_does_not_become_active(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
)
def fake_systemctl(args, *, check=True):
if args[:2] == ["is-active", "--quiet"]:
raise subprocess.CalledProcessError(3, ["systemctl", "--user", *args])
return subprocess.CompletedProcess(["systemctl", "--user", *args], 0)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(mcp_service_runtime, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(mcp_service_runtime, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(mcp_service_runtime, "run_systemctl", side_effect=fake_systemctl),
):
with self.assertRaisesRegex(RuntimeError, "did not become active"):
manage_mcp_service.start_service(project_root=project_root)
def test_show_config_reports_saved_config_not_running_claim(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
)
with (
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(mcp_service_runtime, "service_state", return_value="inactive", create=True),
):
output = manage_mcp_service.show_config(project_root)
self.assertIn("Saved MCP client config.", output)
self.assertIn("Service status: inactive", output)
self.assertNotIn("MCP service is running.", output)
def test_service_status_output_includes_paths_url_and_probe_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
manage_mcp_service.write_token_env_file(
project_root / ".runtime" / "nocobase-crm-mcp.env",
"saved-token",
port=10087,
path="/mcp",
)
with (
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(mcp_service_runtime, "service_state", return_value="active", create=True),
patch.object(
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
),
):
output = manage_mcp_service.service_status(project_root=project_root)
self.assertIn("Service status: active", output)
self.assertIn("Unit file:", output)
self.assertIn("Env file:", output)
self.assertIn("http://127.0.0.1:10087/mcp", output)
self.assertIn("NocoBase CLI env: nb-mcp", output)
self.assertIn("Attachment uploads: disabled (safe default)", output)
self.assertIn("HTTP probe: reachable", output)
def test_service_status_reports_canonical_credentials_without_exposing_them(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
manage_mcp_service.write_token_env_file(env_path, "mcp-token")
with env_path.open("a", encoding="utf-8") as env_file:
env_file.write("NOCOBASE_MCP_API_BASE_URL=https://nb.example/api\n")
env_file.write("NOCOBASE_MCP_API_TOKEN=never-print-this\n")
with (
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]),
patch.object(mcp_service_runtime, "service_state", return_value="inactive"),
patch.object(
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": False, "error": "stopped"},
),
):
output = manage_mcp_service.service_status(project_root=project_root)
self.assertIn("Legacy NocoBase credential override: configured (canonical)", output)
self.assertNotIn("never-print-this", output)
self.assertNotIn("https://nb.example/api", output)
if __name__ == "__main__":
unittest.main()
-387
View File
@@ -1,387 +0,0 @@
import json
import os
import subprocess
import unittest
from unittest.mock import patch
from nocobase_crm_mcp.nb_client import NbAuthenticationError, NbClient, NbCommandError
class NbClientTests(unittest.TestCase):
def test_list_records_builds_safe_nb_command_and_environment(self) -> None:
calls: list[dict] = []
def runner(cmd, *, cwd, env, timeout, capture_output, text, check):
calls.append(
{
"cmd": cmd,
"cwd": cwd,
"env": env,
"timeout": timeout,
"capture_output": capture_output,
"text": text,
"check": check,
}
)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[{"id":1}],"meta":{"count":1}}', stderr="")
client = NbClient(env_name="nb-mcp", data_source="main", runner=runner)
result = client.list_records(
"customers",
filter={"$and": [{"status": {"$eq": "active"}}]},
fields=["id", "name"],
sort=["-updatedAt"],
page=2,
page_size=20,
)
self.assertEqual(result["data"], [{"id": 1}])
command = calls[0]["cmd"]
self.assertEqual(command[:4], ["nb", "api", "resource", "list"])
self.assertIn("--resource", command)
self.assertIn("customers", command)
self.assertIn("--env", command)
self.assertIn("nb-mcp", command)
self.assertIn("--yes", command)
self.assertIn("--json-output", command)
self.assertIn("--data-source", command)
self.assertIn("main", command)
self.assertIn("--filter", command)
self.assertIn(json.dumps({"$and": [{"status": {"$eq": "active"}}]}, separators=(",", ":")), command)
self.assertIn("--fields", command)
self.assertIn("id", command)
self.assertIn("--sort", command)
self.assertIn("-updatedAt", command)
self.assertEqual(calls[0]["env"]["NB_CLI_LOG_DISABLED"], "1")
self.assertEqual(calls[0]["env"]["NB_SKIP_STARTUP_UPDATE"], "1")
self.assertTrue(calls[0]["capture_output"])
self.assertTrue(calls[0]["text"])
self.assertFalse(calls[0]["check"])
def test_api_base_url_and_token_override_saved_env_auth_for_every_api_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",
api_base_url="https://nb.example/api/",
api_token="long-lived-key",
runner=runner,
)
client.list_records("customers")
client.list_collection_fields("customers")
for command in commands:
self.assertEqual(command[command.index("--api-base-url") + 1], "https://nb.example/api")
self.assertEqual(command[command.index("--token") + 1], "long-lived-key")
def test_health_uses_token_aware_resource_probe(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[],"meta":{"count":0}}', stderr="")
health = NbClient(api_token="long-lived-key", runner=runner).health("customers", "id")
self.assertTrue(health["ok"])
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "list"])
self.assertEqual(commands[0][commands[0].index("--resource") + 1], "customers")
self.assertEqual(commands[0][commands[0].index("--fields") + 1], "id")
self.assertEqual(commands[0][commands[0].index("--page-size") + 1], "1")
self.assertEqual(commands[0][commands[0].index("--token") + 1], "long-lived-key")
def test_environment_credentials_reads_saved_user_login_without_token_in_argv(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(
cmd,
0,
stdout=json.dumps(
{
"api": {
"apiBaseUrl": "https://nb.example/api/",
"auth": {"type": "token", "accessToken": "user-access-token"},
}
}
),
stderr="",
)
credentials = NbClient(env_name="customer-prod", runner=runner).environment_credentials()
self.assertEqual(credentials, ("https://nb.example/api", "user-access-token"))
self.assertEqual(
commands[0],
["nb", "env", "info", "customer-prod", "--json", "--show-secrets"],
)
self.assertNotIn("user-access-token", commands[0])
def test_environment_credentials_requires_the_users_cli_login(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd,
0,
stdout=json.dumps(
{
"api": {
"apiBaseUrl": "https://nb.example/api",
"auth": {"type": "token", "accessToken": "-"},
}
}
),
stderr="",
)
with self.assertRaises(NbAuthenticationError) as caught:
NbClient(env_name="customer-prod", runner=runner).environment_credentials()
self.assertIn("personal NocoBase API Key", str(caught.exception))
self.assertNotIn("nb env auth", str(caught.exception))
def test_get_record_and_query_records_use_expected_commands(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)
client.get_record("customers", 7, fields=["id"])
client.query_records(
"customers",
measures=[{"field": ["id"], "aggregation": "count", "alias": "count"}],
dimensions=[{"field": ["status"], "alias": "status"}],
orders=[{"field": ["status"], "alias": "status", "order": "asc"}],
limit=10,
timezone="+08:00",
)
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "get"])
self.assertIn("--filter-by-tk", commands[0])
self.assertIn("7", commands[0])
self.assertEqual(commands[1][:4], ["nb", "api", "resource", "query"])
self.assertIn("--measures", commands[1])
self.assertIn("--dimensions", commands[1])
self.assertIn("--orders", commands[1])
self.assertIn("--timezone", commands[1])
def test_absolute_nb_bin_prepends_its_directory_to_subprocess_path(self) -> None:
calls: list[dict] = []
def runner(cmd, *, env, **kwargs):
calls.append({"cmd": cmd, "env": env})
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True):
NbClient(env_name="nb-mcp", nb_bin="/opt/node/bin/nb", runner=runner).list_records("customers")
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]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(
cmd,
0,
stdout='{"data":[{"name":"f_customer_name","title":"客户名称","interface":"input"}]}',
stderr="",
)
result = NbClient(env_name="nb-mcp", runner=runner).list_collection_fields("t_nt3pjadelqi")
self.assertEqual(result["data"][0]["name"], "f_customer_name")
self.assertEqual(
commands[0][:6],
["nb", "api", "data-modeling", "collections", "fields", "list"],
)
self.assertIn("--collection-name", commands[0])
self.assertIn("t_nt3pjadelqi", commands[0])
self.assertIn("--env", commands[0])
self.assertIn("nb-mcp", commands[0])
self.assertIn("--yes", commands[0])
self.assertIn("--json-output", commands[0])
def test_get_collection_uses_data_modeling_command(self) -> None:
commands: list[list[str]] = []
def runner(cmd, **kwargs):
commands.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"name":"t_nt3pjadelqi"}}', stderr="")
NbClient(env_name="nb-mcp", runner=runner).get_collection("t_nt3pjadelqi", appends=["fields"])
self.assertEqual(commands[0][:5], ["nb", "api", "data-modeling", "collections", "get"])
self.assertIn("--filter-by-tk", commands[0])
self.assertIn("t_nt3pjadelqi", commands[0])
self.assertIn("--appends", commands[0])
self.assertIn("fields", commands[0])
def test_command_errors_redact_api_token(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd,
1,
stdout="",
stderr="Authorization failed for Bearer super-secret-token and --token super-secret-token",
)
client = NbClient(env_name="nb-mcp", api_token="super-secret-token", runner=runner)
with self.assertRaises(NbCommandError) as caught:
client.list_records("customers")
message = str(caught.exception)
self.assertNotIn("super-secret-token", message)
self.assertIn("<redacted>", message)
def test_authentication_failures_have_a_specific_error_type(self) -> None:
cases = (
"Request failed with status 401",
'{"errors":[{"code":"EXPIRED_TOKEN"}]}',
)
for stderr in cases:
with self.subTest(stderr=stderr):
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=stderr)
with self.assertRaises(NbAuthenticationError):
NbClient(api_token="secret", runner=runner).list_records("customers")
def test_plain_forbidden_error_remains_an_acl_command_failure(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd, 1, stdout="", stderr="Request failed with status 403: No permissions"
)
with self.assertRaises(NbCommandError) as caught:
NbClient(api_token="secret", runner=runner).list_records("customers")
self.assertNotIsInstance(caught.exception, NbAuthenticationError)
def test_invalid_json_output_raises_command_error(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout="HTTP 200", stderr="")
client = NbClient(env_name="nb-mcp", runner=runner)
with self.assertRaises(NbCommandError):
client.list_records("customers")
def test_json_output_parser_tolerates_one_surrounding_log_message(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd,
0,
stdout='startup log\n{"data":[{"id":1}],"meta":{"count":1}}\nfinished',
stderr="",
)
result = NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
self.assertEqual(result["data"], [{"id": 1}])
def test_json_output_parser_rejects_multiple_top_level_objects(self) -> None:
def runner(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout='{"log":1}\n{"data":[]}', stderr="")
with self.assertRaises(NbCommandError):
NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
def test_environment_overrides_can_change_nb_home_without_shell(self) -> None:
captured_env = {}
def runner(cmd, *, env, **kwargs):
captured_env.update(env)
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":[]}', stderr="")
with patch.dict(os.environ, {"NB_CLI_ROOT": "/tmp/custom-nb-root"}, clear=True):
NbClient(env_name="nb-mcp", runner=runner).list_records("customers")
self.assertEqual(captured_env["NB_CLI_ROOT"], "/tmp/custom-nb-root")
if __name__ == "__main__":
unittest.main()
-268
View File
@@ -1,268 +0,0 @@
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: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((str(request.url), request.headers["authorization"]))
if request.url.path.endswith("/roles:check"):
return httpx.Response(
200,
json={"data": {"strategy": {"actions": ["view", "create"]}, "resources": []}},
)
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[0][0].endswith("/roles:check"))
self.assertTrue(seen[1][0].endswith("/attachments:create?attachmentField=t_projects.f_projects_attachments"))
self.assertEqual([auth for _url, auth in seen], ["Bearer jwt", "Bearer jwt"])
def test_upload_accepts_collection_specific_project_update_permission(self) -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/roles:check"):
return httpx.Response(
200,
json={
"data": {
"strategy": {"actions": ["view:own"]},
"resources": ["t_projects"],
"actions": {"t_projects:update": {}},
}
},
)
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.assertEqual(len(requests), 2)
def test_upload_rejects_role_without_project_write_before_attachment_create(self) -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={"data": {"strategy": {"actions": ["view:own", "trigger:own"]}, "resources": []}},
)
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))
with self.assertRaises(NocoBaseHttpError) as caught:
client.upload_attachment(path, "quote.pdf", "application/pdf")
self.assertEqual(caught.exception.status_code, 403)
self.assertFalse(caught.exception.authentication_failed)
self.assertEqual([request.url.path for request in requests], ["/api/roles:check"])
def test_upload_accepts_root_role_and_main_data_source_acl(self) -> None:
role_payloads = (
{"data": {"allowAll": True}},
{
"data": {"strategy": {"actions": ["view:own"]}, "resources": []},
"meta": {
"dataSources": {
"main": {
"strategy": {"actions": ["view", "update:own"]},
"resources": [],
}
}
},
},
)
for role_payload in role_payloads:
with self.subTest(role_payload=role_payload), TemporaryDirectory() as directory:
path = Path(directory) / "quote.pdf"
path.write_bytes(b"pdf")
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/roles:check"):
return httpx.Response(200, json=role_payload)
return httpx.Response(200, json={"data": {"id": 42}})
client = NocoBaseHttpClient("https://nb.example/api", "jwt", transport=httpx.MockTransport(handler))
self.assertEqual(client.upload_attachment(path, "quote.pdf", "application/pdf")["id"], 42)
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_credential_provider_is_resolved_per_request_for_local_users(self) -> None:
requests: list[httpx.Request] = []
credentials = iter(
[
("https://nb.example/api", "first-user-token"),
("https://nb.example/api", "refreshed-user-token"),
]
)
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(204)
client = NocoBaseHttpClient(
credential_provider=lambda: next(credentials),
transport=httpx.MockTransport(handler),
)
client.add_attachment_ids(7, [8])
client.remove_attachment_ids(7, [9])
self.assertEqual(requests[0].headers["authorization"], "Bearer first-user-token")
self.assertEqual(requests[1].headers["authorization"], "Bearer refreshed-user-token")
def test_credential_provider_failure_is_reported_as_authentication_failure(self) -> None:
def missing_login() -> tuple[str, str]:
raise RuntimeError("run nb env auth customer-prod")
client = NocoBaseHttpClient(
credential_provider=missing_login,
transport=httpx.MockTransport(lambda request: self.fail("request must not be sent")),
)
with self.assertRaises(NocoBaseHttpError) as caught:
client.add_attachment_ids(7, [8])
self.assertTrue(caught.exception.authentication_failed)
self.assertIn("nb env auth customer-prod", str(caught.exception))
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))
def test_http_error_distinguishes_invalid_key_from_acl_forbidden(self) -> None:
responses = (
(httpx.Response(401, json={"errors": [{"code": "INVALID_TOKEN"}]}), True),
(httpx.Response(403, json={"errors": [{"code": "EXPIRED_TOKEN"}]}), True),
(httpx.Response(403, json={"errors": [{"code": "NO_PERMISSIONS"}]}), False),
)
for response, authentication_failed in responses:
with self.subTest(status=response.status_code, authentication_failed=authentication_failed):
client = NocoBaseHttpClient(
"https://nb.example/api",
"api-key",
transport=httpx.MockTransport(lambda request, response=response: response),
)
with self.assertRaises(NocoBaseHttpError) as caught:
client.add_attachment_ids(7, [8])
self.assertEqual(caught.exception.authentication_failed, authentication_failed)
if __name__ == "__main__":
unittest.main()
-208
View File
@@ -1,208 +0,0 @@
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 = [
(NocoBaseHttpError(404, "attachment request failed"), "attachment_not_found"),
(NocoBaseHttpError(401, "attachment request failed"), "nb_upload_unauthorized"),
(NocoBaseHttpError(403, "permission denied"), "nb_upload_failed"),
(NocoBaseHttpError(403, "expired", authentication_failed=True), "nb_upload_unauthorized"),
(NocoBaseHttpError(500, "attachment request failed"), "nb_upload_failed"),
]
for upstream_error, error_code in cases:
with self.subTest(status_code=upstream_error.status_code, error_code=error_code):
self.setUp()
self.http.add_error = upstream_error
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()
-521
View File
@@ -1,521 +0,0 @@
import asyncio
import unittest
from contextlib import asynccontextmanager
from unittest.mock import patch
import httpx
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from nocobase_crm_mcp import server as server_module
from nocobase_crm_mcp.nb_client import NbAuthenticationError, NbCommandError
from nocobase_crm_mcp.server import (
DEFAULT_HTTP_HOST,
DEFAULT_HTTP_PATH,
DEFAULT_HTTP_PORT,
BearerTokenAuthMiddleware,
FastMCP,
_make_write_services,
_read_nocobase_environment,
create_http_app,
create_server,
run_http_server,
)
async def _ok_app(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
def run_asgi_request(app, *, authorization=None, method="POST", path="/mcp"):
events = []
headers = []
if authorization is not None:
headers.append((b"authorization", authorization.encode("latin1")))
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
events.append(message)
scope = {"type": "http", "method": method, "path": path, "headers": headers}
asyncio.run(app(scope, receive, send))
return events
class FakeCrmService:
def crm_health(self):
return {"ok": True}
def crm_schema(self, include_fields=False):
return {"include_fields": include_fields}
def crm_overview(self, **kwargs):
return {"totals": {"customers": 1}}
def crm_config_template(self):
return {"collections": {}}
def crm_filter_options(self, **kwargs):
return {"customer_status": []}
def resolve_owner(self, **kwargs):
return {"results": []}
def list_customers(self, **kwargs):
return {"results": []}
def get_customer(self, **kwargs):
return {"customer": None}
def list_contacts(self, **kwargs):
return {"results": []}
def list_followups(self, **kwargs):
return {"results": []}
class ErrorCrmService(FakeCrmService):
def list_customers(self, **kwargs):
raise NbCommandError("nb command failed with exit code 1: denied")
class AuthenticationErrorCrmService(FakeCrmService):
def list_customers(self, **kwargs):
raise NbAuthenticationError("Request failed with status 401")
class MissingNbCrmService(FakeCrmService):
def crm_health(self):
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})
async def run_asgi_lifespan(app, on_started):
messages = asyncio.Queue()
await messages.put({"type": "lifespan.startup"})
async def receive():
return await messages.get()
async def send(message):
if message["type"] == "lifespan.startup.complete":
on_started()
await messages.put({"type": "lifespan.shutdown"})
await app({"type": "lifespan"}, receive, send)
class ServerTests(unittest.TestCase):
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
self.assertEqual(run_asgi_request(app)[0]["status"], 401)
self.assertEqual(run_asgi_request(app, authorization="Bearer wrong")[0]["status"], 401)
def test_bearer_token_middleware_allows_matching_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
events = run_asgi_request(app, authorization="Bearer secret")
self.assertEqual(events[0]["status"], 200)
self.assertEqual(events[1]["body"], b"ok")
def test_bearer_token_middleware_allows_options_preflight_without_token(self) -> None:
app = BearerTokenAuthMiddleware(_ok_app, "secret")
events = run_asgi_request(app, method="OPTIONS")
self.assertEqual(events[0]["status"], 204)
def test_readonly_server_exposes_crm_tools_only(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=FakeCrmService(), readonly=True)
tools = asyncio.run(server.list_tools())
self.assertEqual(
{tool.name for tool in tools},
{
"crm_health",
"crm_schema",
"crm_overview",
"crm_filter_options",
"resolve_owner",
"list_teams",
"resolve_team",
"get_team",
"team_crm_overview",
"list_customers",
"get_customer",
"list_contacts",
"list_followups",
"list_projects",
"get_project",
"get_project_timeline",
"list_stale_customers",
},
)
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")
server = create_server(crm_service=FakeCrmService(), readonly=True)
tools = {tool.name: tool for tool in asyncio.run(server.list_tools())}
self.assertIn("include_raw", tools["list_customers"].inputSchema["properties"])
self.assertIn("include_raw", tools["list_contacts"].inputSchema["properties"])
self.assertIn("include_raw", tools["list_followups"].inputSchema["properties"])
self.assertIn("include_raw", tools["get_customer"].inputSchema["properties"])
self.assertIn("include_details", tools["list_contacts"].inputSchema["properties"])
def test_raw_rows_require_explicit_local_policy_opt_in(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=FakeCrmService(), readonly=True)
with patch.dict("os.environ", {}, clear=True):
_content, blocked = asyncio.run(server.call_tool("list_customers", {"include_raw": True}))
with patch.dict("os.environ", {"NOCOBASE_MCP_ENABLE_RAW_DATA": "1"}, clear=True):
_content, allowed = asyncio.run(server.call_tool("list_customers", {"include_raw": True}))
self.assertEqual(blocked["error"]["code"], "raw_data_disabled")
self.assertEqual(allowed, {"results": []})
def test_tools_return_structured_error_for_nb_failures(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=ErrorCrmService(), readonly=True)
_content, structured = asyncio.run(server.call_tool("list_customers", {}))
self.assertEqual(structured["error"]["code"], "nb_command_failed")
self.assertIn("denied", structured["error"]["message"])
self.assertFalse(structured["error"]["retryable"])
def test_tools_return_specific_error_for_missing_or_expired_user_login(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=AuthenticationErrorCrmService(), readonly=True)
_content, structured = asyncio.run(server.call_tool("list_customers", {}))
self.assertEqual(structured["error"]["code"], "nb_auth_failed")
self.assertIn("secure connect-key flow", structured["error"]["hint"])
self.assertIn("private local prompt", structured["error"]["hint"])
self.assertFalse(structured["error"]["retryable"])
def test_tools_return_specific_error_when_nb_is_not_found(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=MissingNbCrmService(), readonly=True)
_content, structured = asyncio.run(server.call_tool("crm_health", {}))
self.assertEqual(structured["error"]["code"], "nb_not_found")
self.assertIn("NOCOBASE_MCP_NB_BIN", structured["error"]["hint"])
self.assertFalse(structured["error"]["retryable"])
def test_make_service_uses_nb_bin_from_environment(self) -> None:
with (
patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "/opt/node/bin/nb"}),
patch.object(server_module, "NbClient") as nb_client,
patch.object(
server_module, "CrmService", side_effect=lambda config, client: {"config": config, "client": client}
),
):
service = server_module._make_service()
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_is_not_exposed_without_an_endpoint(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")
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"})
self.assertEqual(asyncio.run(request_upload()).status_code, 404)
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_upload_route_dispatches_callable_endpoint_objects_as_request_handlers(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())
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_upload_route_preserves_mcp_application_lifespan(self) -> None:
lifecycle = {"started": False, "stopped": False}
@asynccontextmanager
async def lifespan(_app):
lifecycle["started"] = True
try:
yield
finally:
lifecycle["stopped"] = True
class FakeServer:
def streamable_http_app(self):
return Starlette(lifespan=lifespan)
app = create_http_app(FakeServer(), "secret", upload_endpoint=FakeUploadEndpoint())
asyncio.run(run_asgi_lifespan(app, lambda: self.assertTrue(lifecycle["started"])))
self.assertTrue(lifecycle["stopped"])
def test_nocobase_environment_prefers_canonical_shared_credentials(self) -> None:
with patch.dict(
"os.environ",
{
"NOCOBASE_MCP_API_BASE_URL": "https://nb.example/api/",
"NOCOBASE_MCP_API_TOKEN": "shared-key",
"NOCOBASE_MCP_UPLOAD_MAX_BYTES": "123",
},
clear=True,
):
self.assertEqual(
_read_nocobase_environment(),
("https://nb.example/api", "shared-key", 123),
)
def test_nocobase_environment_defaults_to_the_users_saved_cli_login(self) -> None:
with patch.dict("os.environ", {}, clear=True):
self.assertEqual(_read_nocobase_environment(), (None, None, 26214400))
def test_nocobase_environment_accepts_matching_legacy_credentials(self) -> None:
with patch.dict(
"os.environ",
{
"NOCOBASE_MCP_UPLOAD_API_BASE_URL": "https://legacy.example/api",
"NOCOBASE_MCP_UPLOAD_JWT": "legacy-key",
},
clear=True,
):
self.assertEqual(
_read_nocobase_environment(),
("https://legacy.example/api", "legacy-key", 26214400),
)
def test_nocobase_environment_rejects_incomplete_or_conflicting_credentials(self) -> None:
cases = (
{"NOCOBASE_MCP_API_BASE_URL": "https://nb.example/api"},
{
"NOCOBASE_MCP_API_BASE_URL": "https://nb.example/api",
"NOCOBASE_MCP_API_TOKEN": "canonical",
"NOCOBASE_MCP_UPLOAD_API_BASE_URL": "https://other.example/api",
"NOCOBASE_MCP_UPLOAD_JWT": "legacy",
},
)
for values in cases:
with self.subTest(values=set(values)), patch.dict("os.environ", values, clear=True):
with self.assertRaises(RuntimeError):
_read_nocobase_environment()
def test_write_services_use_the_selected_nb_environment_credentials_by_default(self) -> None:
with (
patch.object(server_module, "NbClient") as nb_client,
patch.object(server_module, "CrmService") as crm_service,
patch.object(server_module, "ProjectService") as project_service,
patch.object(server_module, "NocoBaseHttpClient") as http_client,
):
result = _make_write_services()
nb_client.assert_called_once_with(
env_name="nb-mcp",
data_source="main",
nb_bin="nb",
api_base_url=None,
api_token=None,
)
http_client.assert_called_once_with(credential_provider=nb_client.return_value.environment_credentials)
crm_service.assert_called_once()
project_service.assert_called_once()
self.assertEqual(result, (crm_service.return_value, project_service.return_value, http_client.return_value))
def test_http_write_mode_uses_saved_cli_login_without_shared_api_credentials(self) -> None:
with patch.dict("os.environ", {"NOCOBASE_MCP_TOKEN": "secret"}, clear=True):
with (
patch.object(server_module, "_make_write_services") as make_services,
patch.object(server_module, "create_server") as create_server_mock,
patch.object(server_module, "UploadEndpoint"),
patch.object(server_module, "create_http_app", return_value="app"),
patch("uvicorn.run") as uvicorn_run,
):
make_services.return_value = ("crm", "projects", "http")
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,
)
make_services.assert_called_once_with(None, api_base_url=None, api_token=None)
create_server_mock.assert_called_once()
uvicorn_run.assert_called_once_with(
"app",
host=DEFAULT_HTTP_HOST,
port=DEFAULT_HTTP_PORT,
log_level="info",
)
def test_http_upload_route_is_disabled_by_default(self) -> None:
with patch.dict("os.environ", {"NOCOBASE_MCP_TOKEN": "secret"}, clear=True):
with (
patch.object(server_module, "_make_write_services") as make_services,
patch.object(server_module, "create_server") as create_server_mock,
patch.object(server_module, "UploadEndpoint") as upload_endpoint,
patch.object(server_module, "create_http_app", return_value="app") as create_http_app_mock,
patch("uvicorn.run"),
):
make_services.return_value = ("crm", "projects", "http")
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,
)
upload_endpoint.assert_not_called()
create_http_app_mock.assert_called_once_with(create_server_mock.return_value, "secret", upload_endpoint=None)
def test_http_upload_route_requires_explicit_local_opt_in(self) -> None:
with patch.dict(
"os.environ",
{"NOCOBASE_MCP_TOKEN": "secret", "NOCOBASE_MCP_ENABLE_UPLOADS": "1"},
clear=True,
):
with (
patch.object(server_module, "_make_write_services") as make_services,
patch.object(server_module, "create_server") as create_server_mock,
patch.object(server_module, "UploadEndpoint") as upload_endpoint,
patch.object(server_module, "create_http_app", return_value="app") as create_http_app_mock,
patch("uvicorn.run"),
):
make_services.return_value = ("crm", "projects", "http")
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,
)
upload_endpoint.assert_called_once()
create_http_app_mock.assert_called_once_with(
create_server_mock.return_value,
"secret",
upload_endpoint=upload_endpoint.return_value,
)
def test_config_template_tool_is_opt_in(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
with patch.dict("os.environ", {"NOCOBASE_MCP_ENABLE_CONFIG_TOOLS": "1"}):
server = create_server(crm_service=FakeCrmService(), readonly=True)
tools = asyncio.run(server.list_tools())
self.assertIn("crm_config_template", {tool.name for tool in tools})
def test_create_http_app_requires_bearer_token(self) -> None:
if FastMCP is None:
self.skipTest("MCP SDK is not installed")
server = create_server(crm_service=FakeCrmService(), readonly=True)
with self.assertRaises(ValueError):
create_http_app(server, "")
if __name__ == "__main__":
unittest.main()
-229
View File
@@ -1,229 +0,0 @@
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 = (
(NocoBaseHttpError(401, "upstream failure"), "nb_upload_unauthorized"),
(NocoBaseHttpError(403, "expired", authentication_failed=True), "nb_upload_unauthorized"),
(NocoBaseHttpError(500, "upstream failure"), "nb_upload_failed"),
)
for upstream_error, error_code in cases:
with (
self.subTest(status_code=upstream_error.status_code, error_code=error_code),
TemporaryDirectory() as temp_dir,
):
http_client = FakeHttpClient()
http_client.error = upstream_error
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_maps_project_write_denial_to_forbidden(self) -> None:
http_client = FakeHttpClient()
http_client.error = NocoBaseHttpError(403, "permission denied")
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, 403)
self.assertEqual(response.json()["error"]["code"], "nb_upload_forbidden")
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()
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.mjs"]
}
Generated
-1357
View File
File diff suppressed because it is too large Load Diff