6 Commits
Author SHA1 Message Date
Donaldttt 7eb416ded1 Merge pull request #8 from Donaldttt/feat/guided-one-command-install
feat: add guided one-command installer
2026-08-15 11:48:30 +08:00
nac 42851a3d90 feat: add guided one-command installer 2026-08-15 03:47:17 +00:00
Donaldttt 6316732b23 Merge pull request #7 from Donaldttt/fix/lean-end-user-install
fix: reduce end-user installation footprint
2026-08-15 11:00:45 +08:00
nac 5288590893 fix: reduce end-user installation footprint 2026-08-15 02:59:28 +00:00
Donaldttt 5d3122b7e4 Merge pull request #6 from Donaldttt/refactor/structure-simplification
refactor: simplify CRM MCP architecture
2026-08-15 09:47:52 +08:00
nac e4be04a4c3 refactor: simplify CRM MCP architecture 2026-08-15 01:46:34 +00:00
41 changed files with 5277 additions and 4335 deletions
+55 -109
View File
@@ -1,151 +1,97 @@
# Agent 安装入口:NocoBase CRM MCP
# 安装 NocoBase CRM MCP
这是一份供任意终端型 Agent 执行、也供用户审阅的安装协议。它不依赖 Codex、Claude Code、Cursor、OpenCode 或其他单一产品的插件格式。MCP 是可移植的功能层;Agent 只需把最后生成的 stdio 启动配置转换成自己客户端支持的格式。
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定产品。用户只需要提供本链接、确认一次本机变更,并在隐藏提示中输入自己的 NocoBase API Key。
如果你是收到本链接的 Agent:请完整阅读本文后再执行命令,按顺序持续工作到“完成条件”全部满足。不要把本文当成需要原样转发给用户的普通说明;你应根据操作系统、Shell、已有软件和当前 Agent 客户端自适应执行,并向用户报告关键检查点。
如果你是安装 Agent,请直接完成下面的流程,不要把命令转交给用户逐条尝试。只有在需要用户确认安装内容、提供 NocoBase 地址或在隐藏提示中粘贴 Key 时才打断用户。
## 不可变安全规则
## 完成目标
1. 只安装 [`install-manifest.json`](install-manifest.json) 指定的仓库和 `releaseRef`,不得默认安装 `master`、`main`、开发分支或其他未固定版本。
2. 不使用 `curl | sh`、`wget | sh` 或同类远程脚本管道。缺少全局依赖时,先说明来源、版本、影响范围并取得用户同意,再采用该工具的官方安装方式。
3. 不使用 `sudo`,不删除或覆盖未知目录,不丢弃已有 Git 改动。目标目录已存在时先识别它;不是本项目或工作区不干净时停止覆盖并向用户说明。
4. 认证默认使用用户自己的个人 API Key(Authorize Key),不使用账号密码。Agent 可以引导用户在 NocoBase 页面创建 Key,但不得要求用户把 Key 发到聊天;Key 只能由用户粘贴到本机、用户可控、隐藏回显的安全输入提示中。
5. 默认只读。除非用户明确要求且管理员已确认 NocoBase ACL,否则不得启用 `--enable-writes`、附件上传或原始数据输出。
6. 写入 Agent/MCP 客户端配置前,显示目标文件和将新增的 server 名称,创建可恢复备份,并只合并 `nocobase-crm` 条目;不得覆盖其他 MCP 配置。
7. 不把本机 MCP 端口暴露到局域网或公网。通用安装默认使用 stdio,不创建后台服务,也不生成共享业务凭证。
安装完成时必须同时满足:
## 安装输入
- 使用固定 Release `v0.2.0`,不是开发分支;
- MCP 在用户本机以 stdio 运行,默认只读;
- NocoBase 认证属于当前用户自己的个人 Key 和业务角色;
- 当前 MCP 客户端已注册 `nocobase-crm`;
- `crm_health` 返回 `ok=true`。
从用户或当前上下文确定以下内容;已有明确答案时不要重复提问:
## 不可违反的安全规则
- NocoBase 应用 URL,例如 `https://nocobase.example`。安装器会规范化为以 `/api` 结尾;远程环境必须使用 HTTPS。
- 安装目录。使用该操作系统的普通用户应用/数据目录作为默认值,并在写入前告知用户;不要默认放在系统目录。
- 当前 Agent/MCP 客户端的名称及其本机 MCP 配置位置。
- 功能模式。未明确选择时必须使用“只读”。
- 不索取账号密码,不使用共享服务账号或管理员 Key。
- 不让用户把 Key 发到聊天、命令参数、环境变量或临时文件。
- Key 只能由用户粘贴到 `nb` CLI 的本机隐藏 TTY 提示。
- 不使用 `sudo`,不执行 `curl | sh` / `wget | sh`。
- 写 MCP 客户端配置前必须展示目标路径、创建备份并结构化合并,不能覆盖其他 server。
- 除非用户明确要求且管理员已确认 ACL,否则不启用写入、上传或原始数据。
- 默认不创建后台服务、不监听端口、不暴露到局域网或公网。
不要要求用户在 Agent 对话中提供 NocoBase Key、密码、Token、Cookie 或 `~/.nocobase` 目录。Key 只进入 `nb` CLI 自己的本机隐藏提示。
## 一次安装流程
## 执行协议
### 1. 确认输入和访问权限
### 1. 读取并锁定发布版本
从用户或上下文取得:
读取同版本的 `install-manifest.json`。本版本的预期来源是:
- NocoBase 应用 URL,例如 `https://nocobase.example`;
- 普通用户安装目录;
- 当前 Agent/MCP 客户端及其配置路径。
```text
repository: https://github.com/Donaldttt/nocobase-mcp.git
releaseRef: v0.2.0
```
本项目暂时通过 Private GitHub 仓库分发。当前 Agent 必须已经连接一个对 `Donaldttt/nocobase-mcp` 有只读权限的 GitHub 账号;无权读取时停止,不得退回 `master`。
若 `v0.2.0` 尚不存在,或 Agent 无权读取私有仓库,停止安装并清楚报告“发布物/访问权限尚未就绪”,不得退回开发分支。面向普通外部用户发布时,安装方必须能匿名读取本页和发布物;私有分发则需要用户已连接有权访问该仓库的 GitHub 账号。
### 2. 一次性说明本机变更
首次安装使用等效于以下语义的 Git 命令,并根据当前 Shell 正确转义路径:
检查 Git、uv、Python 3.12+ 和 NocoBase `nb` CLI 2.1+。若缺失,向用户一次性说明将安装的工具、官方来源和位置,并取得一次确认后再处理。只有缺少 `nb` 时才可能额外需要 Node.js/npm;官方 CLI 包为 `@nocobase/cli`。
用户安装会留下:固定版本仓库副本、项目 `.venv`、不含 Key 的 `config.json`、`nb` CLI 用户登录环境、MCP 客户端配置和配置备份。不会默认创建 systemd 服务或 HTTP Token。
### 3. 取得固定版本
安全克隆并验证 Release:
```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
```
第二条必须精确输出 `v0.2.0`。已有安装的更新不得直接 `git pull` 开发分支;先保存当前版本和 `config.json`,确认工作区干净,再获取并切换到用户指定的新 Release Tag。
第二条必须精确输出 `v0.2.0`。目标目录已存在但不是本项目、包含未提交修改或版本不明确时停止覆盖。
### 2. 环境检查与确定性准备
### 4. 只运行一个安装命令
需要:Git、`uv`、Python 3.12+、NocoBase `nb` CLI 2.1+。先运行只读检查:
先引导用户在 NocoBase 个人设置中创建一个可撤销、有合理有效期、绑定本人普通业务角色的个人 API Key。不要让用户把 Key 发给你。
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> inspect --json
```
若 `uv` 或 `nb` 不存在,先取得用户同意再安装。NocoBase CLI 当前官方包入口是 `npm install -g @nocobase/cli`;安装后必须重新运行检查,不要通过修改 PATH 输出伪造成功。
然后运行幂等准备步骤:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> prepare --json
```
该脚本只会校验项目文件、在缺失时从示例创建 `config.json`,并执行锁定依赖同步。已有 `config.json` 会被保留,依赖必须来自 `uv.lock`。
### 3. 连接用户自己的 NocoBase 账号
先引导用户在目标 NocoBase 网页中打开头像或个人设置,寻找当前版本中名为 `API Keys`、`Access Tokens`、`访问令牌` 或类似名称的入口。让用户创建一个:
- 绑定用户本人,而不是共享服务账号;
- 只使用其正常业务角色,不选择管理员/root 角色;
- 有合理过期时间、可单独撤销;
- 仅用于这台电脑上的 MCP。
如果用户看不到个人 Key 功能,Agent 应提示联系 NocoBase 管理员开通或代为签发绑定该用户普通业务角色的 Key;不得改用账号密码或共享管理员 Key。
取得 Key 后,Agent 必须在**用户能直接控制输入的本机终端**中启动以下流程,而不是把命令交给用户手动尝试:
然后在用户可直接控制输入的本机终端中运行:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py \
--project-root <install-dir> connect-key \
--project-root <install-dir> install \
--api-base-url <nocobase-url> --env-name nb-mcp --json
```
安装器会自动:
这个命令会一次完成运行环境准备、隐藏 Key 登录、配置写入、doctor 检查和通用 MCP 客户端配置生成。用户只在 `nb` 的隐藏提示出现时粘贴 Key;Agent 不读取输入。
1. 规范化并验证 HTTPS URL;
2. 脱敏检查已有环境,拒绝覆盖指向其他服务器的同名环境;
3. 调用 `nb env add/auth --auth-type token`,但故意不传 `--access-token`,让 `nb` 在 TTY 内隐藏提示;
4. 由用户本人把 Key 粘贴到这个本机提示中,Agent 不读取输入;
5. 自动执行环境读回、把选定环境写入 `config.json`,并在修改前保存 `config.json.pre-agent-install.bak`。
若终端不支持用户私密输入,命令会在写入前停止。此时报告缺少安全 TTY,不得让用户改为在聊天中发送 Key。
若环境名冲突,安装器会给出新名称建议;Agent 使用建议名称重启同一流程,不覆盖旧环境。若当前 Agent 无法提供用户可直接输入的本机 TTY,必须停止并报告“缺少安全 Key 输入通道”;不得要求用户把 Key 粘到聊天、命令参数、环境变量或临时文件中,也不得把 `nb` 命令交给用户反复尝试。
### 5. 自动完成客户端注册和验证
Key 过期或撤销后,Agent 重新启动 `connect-key`,用户在新的隐私提示中输入新 Key。不得退回共享凭证。
读取命令返回的 `clientConfig`:
### 4. 注册到当前 Agent/MCP 客户端
1. 展示当前客户端配置文件路径和将新增的 `nocobase-crm` 条目;
2. 创建可恢复备份;
3. 结构化合并该 server,保留所有其他配置;
4. 按客户端能力重载配置或重启客户端;
5. 调用 `crm_health`,要求 `ok=true`。
生成不含 NocoBase Token 的通用配置:
只有完成以上验证后才能向用户报告成功。最终只报告安装目录、Release、NocoBase 环境名、客户端配置备份位置和启用模式,不显示任何 Token。
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> client-config
```
## 默认模式与可选写入
输出采用常见的 `mcpServers` JSON 形状,包含绝对的项目、`uv` 和 `nb` 路径。Agent 应查明自己客户端当前版本支持的 MCP 配置格式,将其中的 `command`、`args`、`env` 等价映射过去。
未明确选择时必须保持只读。只有用户明确要求项目创建/更新,且管理员确认该用户角色具有相应 NocoBase ACL 时,才在同一个安装命令末尾加 `--enable-writes`。这仍不会启用附件上传、删除或原始数据。
写入前:
## 故障处理
1. 向用户显示目标客户端配置文件路径和新增的 `nocobase-crm` 条目。
2. 备份原文件;解析后做结构化合并,不能用字符串替换或整文件覆盖。
3. 保留所有其他 server、设置和注释(客户端格式允许时)。
4. 按客户端要求重载 MCP 配置或提醒用户重启客户端。
不要让普通用户手动尝试 `nb` 或拆分安装子命令。由 Agent 根据结构化错误处理;需要详细恢复、更新或高级 HTTP 部署时,阅读同版本的 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。Key 过期或撤销时,重新运行同一个 `install` 命令,让用户在新的隐藏提示中输入新 Key。
只有用户明确要求项目创建/更新,且其管理员确认对应角色 ACL 后,才可重新生成含写工具的配置:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> client-config --enable-writes
```
这仍不启用上传、删除或原始数据。附件上传属于单独的 Linux 本机 HTTP 高级部署,不是通用安装流程的一部分。
### 5. 验证
先检查本机安装,不读取 NocoBase 秘密:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> doctor --json
```
然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成:
- `doctor` 返回 `ok: true`;
- `connect-key` 返回 `ok: true`、`authType: token`,且目标 URL 正确;
- 客户端已发现 `nocobase-crm`;
- `crm_health` 返回 `ok: true`,不是 `nb_auth_failed`、`NO_PERMISSIONS` 或其他结构化错误;
- 默认状态仍是只读、uploads disabled、raw data disabled。
最后报告安装目录、Release Tag、NocoBase 环境名、客户端配置备份位置、启用的能力以及一条最小使用示例。不要报告或回显任何 Token。
## 给用户的最短用法
普通情况下,用户只需把当前**不可变 Release 版本**的 `INSTALL.md` 链接发给具备本机终端和文件权限的 Agent,并说:
## 用户可发送给 Agent 的最短指令
```text
请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;认证使用我本人的个人 API Key,不使用账号密码。请引导我在 NocoBase 获取 Key,并只让我在本机隐藏提示中粘贴;不要让我把 Key 发到聊天,也不要让我手动尝试 nb 命令。安装后必须通过 doctor 和 crm_health:
<version-pinned INSTALL.md URL>
请按这个协议帮我完成本机安装。默认只读,使用我自己的 NocoBase API Key;只让我在本机隐藏提示中输入 Key,不要让我手动执行安装或 nb 命令。安装完成前必须验证 crm_health:
<固定版本 INSTALL.md 链接>
```
如果 Agent 只能聊天、不能执行本机命令或写 MCP 配置,它应说明能力限制并按本文生成逐步人工安装指令,而不能声称已经安装。
+11 -5
View File
@@ -6,15 +6,17 @@ Each user runs this MCP on their own computer and signs in to the remote NocoBas
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.
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.
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.
## Configure
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 starts this helper in a user-controlled local terminal:
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:
```bash
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
```
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.
@@ -186,10 +188,10 @@ Queries and writes use the saved `nb-mcp` personal Key login. For multipart uplo
First-install flow:
```bash
# The Agent first runs connect-key in a user-controlled TTY. The user pastes a
# personal NocoBase API Key only into the hidden prompt; it never enters argv.
# 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 . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
--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
@@ -229,6 +231,10 @@ For either write-enabled mode, the logged-in user additionally needs:
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:
+29
View File
@@ -0,0 +1,29 @@
# Internal architecture
The default data path is:
```text
MCP client -> server facade -> CRM domain mixins -> NbClient -> local nb CLI -> NocoBase
```
The local `nb` environment is the identity boundary. The MCP does not keep a shared NocoBase account, and NocoBase ACL remains authoritative.
## Package boundaries
- `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
- `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.
+20 -18
View File
@@ -13,8 +13,10 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程
## 环境要求
- Python 3.12 或更高版本
- Git(仅安装、版本校验和更新)
- `uv`
- NocoBase `nb` CLI 2.1 或更高版本
- Node.js/npm(仅在尚未安装 `nb` 时用于安装官方 CLI)
- 可通过浏览器访问目标 NocoBase
- 管理员已为用户配置所需的 NocoBase 角色与数据权限
@@ -26,28 +28,26 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程
这个通用流程默认使用 stdio,因此不需要后台服务或本机 MCP Bearer Token。Linux systemd/HTTP 方式只用于确有附件上传需求的高级场景。
## 首次安装
## 首次安装与统一引导
```bash
git clone https://github.com/Donaldttt/nocobase-mcp.git
git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git
cd nocobase-mcp
uv sync --locked
cp config.example.json config.json
```
`config.json` 中的 `env` 默认是 `nb-mcp`。正式终端用户不应手动尝试 `nb env add/auth`;安装 Agent 在用户可直接控制的本机终端中运行:
正式终端用户不应手动尝试依赖同步或 `nb env add/auth`。安装 Agent 在用户可直接控制的本机终端中只运行统一安装命令:
```bash
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
```
Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。
该命令依次准备运行环境、连接个人 Key、运行 doctor,并返回通用 `clientConfig`。Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。
先进行健康检查:
```bash
uv run nocobase-crm-mcp --config config.json
.venv/bin/python -m nocobase_crm_mcp.server --config config.json
```
然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,由 Agent 重新启动 `connect-key`,用户在新的本机隐藏提示中输入新 Key;不要让用户手动尝试 `nb` 命令。
@@ -60,21 +60,23 @@ uv run nocobase-crm-mcp --config config.json
{
"mcpServers": {
"nocobase-crm": {
"command": "uv",
"command": "/absolute/path/to/nocobase-mcp/.venv/bin/python",
"args": [
"--directory",
"/absolute/path/to/nocobase-mcp",
"run",
"--no-sync",
"nocobase-crm-mcp",
"-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`;上传路由本身也默认关闭,不得暴露到局域网或公网:
@@ -120,8 +122,8 @@ Linux 用户也可以执行 `bash scripts/mcp_service.sh start` 安装用户级
```bash
git rev-parse HEAD
git pull --ff-only
uv sync --locked
uv run python -m unittest discover -s tests
uv sync --locked --no-dev
.venv/bin/python -m unittest discover -s tests
```
使用 systemd 本机 HTTP 服务时,再执行:
@@ -136,7 +138,7 @@ journalctl --user -u nocobase-crm-mcp.service -n 200 --no-pager
```bash
git switch --detach <previous-release-tag-or-commit>
uv sync --locked
uv sync --locked --no-dev
bash scripts/mcp_service.sh start
```
+18
View File
@@ -15,8 +15,25 @@
"defaultTransport": "stdio",
"python": ">=3.12",
"packageManager": "uv",
"environment": ".venv",
"launcher": "project-venv-python",
"dependencyProfile": "runtime-only-no-dev",
"nocobaseCli": ">=2.1"
},
"installation": {
"requiredTools": ["git", "uv", "python", "nb"],
"conditionalTools": {
"nodeAndNpm": "Required only when the NocoBase nb CLI is not already installed."
},
"persistentArtifacts": [
"release checkout",
"project .venv",
"config.json",
"nb CLI user environment",
"MCP client configuration and backup"
],
"defaultBackgroundService": false
},
"authentication": {
"type": "personal-api-key",
"cliAuthType": "token",
@@ -28,6 +45,7 @@
"entrypoints": {
"guide": "INSTALL.md",
"helper": "scripts/agent_install.py",
"guidedCommand": "install",
"configTemplate": "config.example.json"
},
"securityDefaults": {
+99 -200
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass, field, replace
from dataclasses import dataclass, field, fields, replace
from pathlib import Path
from typing import Any
@@ -30,9 +30,20 @@ CUSTOMER_DETAIL_FIELDS = {
class CustomerConfig:
resource: str = "customers"
id_field: str = "id"
fields: list[str] | None = None
search_fields: list[str] | None = None
name_fields: list[str] | None = None
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"
@@ -40,61 +51,43 @@ class CustomerConfig:
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 = None
def __post_init__(self) -> None:
if self.fields is None:
object.__setattr__(
self,
"fields",
["id", "name", "code", "status", "source", "owner", "createdAt", "updatedAt"],
)
if self.search_fields is None:
object.__setattr__(self, "search_fields", ["name", "code"])
if self.name_fields is None:
object.__setattr__(
self,
"name_fields",
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name", "name", "title", "code"],
)
if self.sort is None:
object.__setattr__(self, "sort", ["-updatedAt"])
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 = None
search_fields: list[str] | None = None
name_fields: list[str] | None = None
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 = None
def __post_init__(self) -> None:
if self.fields is None:
object.__setattr__(
self,
"fields",
["id", "name", "phone", "email", "title", "customer", "createdAt", "updatedAt"],
)
if self.search_fields is None:
object.__setattr__(self, "search_fields", ["name", "phone", "email"])
if self.name_fields is None:
object.__setattr__(self, "name_fields", ["f_2zpg91gtmwp", "name", "title"])
if self.sort is None:
object.__setattr__(self, "sort", ["-updatedAt"])
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 = None
search_fields: list[str] | None = None
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"
@@ -105,52 +98,26 @@ class FollowupConfig:
status_field: str = "f_hskl7gjvos9"
project_field: str = "f_gjjl_projects"
location_field: str = "f_b5c4hfi5og6"
sort: list[str] | None = None
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 [])
if self.fields is None:
object.__setattr__(
self,
"fields",
[
"id",
"customer",
"contact",
"owner",
"content",
"followedAt",
"nextFollowAt",
"createdAt",
"updatedAt",
],
)
if self.search_fields is None:
object.__setattr__(self, "search_fields", ["content"])
if self.sort is None:
object.__setattr__(self, "sort", ["-followedAt", "-createdAt"])
@dataclass(frozen=True)
class UserConfig:
resource: str = "users"
id_field: str = "id"
fields: list[str] | None = None
match_fields: list[str] | None = None
label_fields: list[str] | None = None
def __post_init__(self) -> None:
if self.fields is None:
object.__setattr__(
self,
"fields",
["id", "username", "nickname", "email", "f_yh_english_name"],
)
if self.match_fields is None:
object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"])
if self.label_fields is None:
object.__setattr__(self, "label_fields", ["nickname", "f_yh_english_name", "username", "email"])
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)
@@ -199,12 +166,12 @@ class CrmConfig:
env_name: str = "nb-mcp"
data_source: str = "main"
timezone: str = "Asia/Shanghai"
customer: CustomerConfig = CustomerConfig()
contact: ContactConfig = ContactConfig()
followup: FollowupConfig = FollowupConfig()
user: UserConfig = UserConfig()
team: TeamConfig = TeamConfig()
project: ProjectConfig = ProjectConfig()
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]:
@@ -238,144 +205,76 @@ def _pick(data: dict[str, Any], *names: str) -> Any:
return None
def _customer_config(data: dict[str, Any] | None) -> CustomerConfig:
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 CustomerConfig()
base = CustomerConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields,
name_fields=_string_list(_pick(data, "nameFields", "name_fields")) or base.name_fields,
english_name_field=str(_pick(data, "englishNameField", "english_name_field") or base.english_name_field),
territorial_name_field=str(
_pick(data, "territorialNameField", "territorial_name_field") or base.territorial_name_field
),
status_field=str(_pick(data, "statusField", "status_field") or base.status_field),
source_field=str(_pick(data, "sourceField", "source_field") or base.source_field),
owner_field=str(_pick(data, "ownerField", "owner_field") or base.owner_field),
manager_field=str(_pick(data, "managerField", "manager_field") or base.manager_field),
detail_fields=_string_map(_pick(data, "detailFields", "detail_fields")) or base.detail_fields,
sort=_string_list(_pick(data, "sort")) or base.sort,
)
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:
if not data:
return ContactConfig()
base = ContactConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields,
name_fields=_string_list(_pick(data, "nameFields", "name_fields")) or base.name_fields,
title_field=str(_pick(data, "titleField", "title_field") or base.title_field),
phone_field=str(_pick(data, "phoneField", "phone_field") or base.phone_field),
email_field=str(_pick(data, "emailField", "email_field") or base.email_field),
customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field),
sort=_string_list(_pick(data, "sort")) or base.sort,
)
return _simple_section(ContactConfig, data)
def _followup_config(data: dict[str, Any] | None) -> FollowupConfig:
if not data:
return FollowupConfig()
base = FollowupConfig()
owner_field = str(_pick(data, "ownerField", "owner_field") or base.owner_field)
owner_fields = _string_list(_pick(data, "ownerFields", "owner_fields")) or ([owner_field] if owner_field else [])
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields,
customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field),
contact_field=str(_pick(data, "contactField", "contact_field") or base.contact_field),
owner_field=owner_field,
owner_fields=owner_fields,
date_field=str(_pick(data, "dateField", "date_field") or base.date_field),
content_field=str(_pick(data, "contentField", "content_field") or base.content_field),
type_field=str(_pick(data, "typeField", "type_field") or base.type_field),
status_field=str(_pick(data, "statusField", "status_field") or base.status_field),
project_field=str(_pick(data, "projectField", "project_field") or base.project_field),
location_field=str(_pick(data, "locationField", "location_field") or base.location_field),
sort=_string_list(_pick(data, "sort")) or base.sort,
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:
if not data:
return UserConfig()
base = UserConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
fields=_string_list(_pick(data, "fields")) or base.fields,
match_fields=_string_list(_pick(data, "matchFields", "match_fields")) or base.match_fields,
label_fields=_string_list(_pick(data, "labelFields", "label_fields")) or base.label_fields,
)
return _simple_section(UserConfig, data)
def _team_config(data: dict[str, Any] | None) -> TeamConfig:
if not data:
return TeamConfig()
base = TeamConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
name_field=str(_pick(data, "nameField", "name_field") or base.name_field),
manager_field=str(_pick(data, "managerField", "manager_field") or base.manager_field),
member_field=str(_pick(data, "memberField", "member_field") or base.member_field),
user_status_field=str(_pick(data, "userStatusField", "user_status_field") or base.user_status_field),
formal_user_status_value=str(
_pick(data, "formalUserStatusValue", "formal_user_status_value") or base.formal_user_status_value
),
)
return _simple_section(TeamConfig, data)
def _project_config(data: dict[str, Any] | None) -> ProjectConfig:
if not data:
return ProjectConfig()
base = ProjectConfig()
return replace(
base,
resource=str(_pick(data, "resource") or base.resource),
id_field=str(_pick(data, "idField", "id_field") or base.id_field),
name_field=str(_pick(data, "nameField", "name_field") or base.name_field),
# Left as None when unset so __post_init__ re-derives it from the configured name_field.
name_fields=_string_list(_pick(data, "nameFields", "name_fields")),
customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field),
customer_relation_field=str(
_pick(data, "customerRelationField", "customer_relation_field") or base.customer_relation_field
),
inquiry_date_field=str(_pick(data, "inquiryDateField", "inquiry_date_field") or base.inquiry_date_field),
progress_field=str(_pick(data, "progressField", "progress_field") or base.progress_field),
applications_field=str(_pick(data, "applicationsField", "applications_field") or base.applications_field),
part_numbers_field=str(_pick(data, "partNumbersField", "part_numbers_field") or base.part_numbers_field),
period_usage_field=str(_pick(data, "periodUsageField", "period_usage_field") or base.period_usage_field),
details_field=str(_pick(data, "detailsField", "details_field") or base.details_field),
attachment_field=str(_pick(data, "attachmentField", "attachment_field") or base.attachment_field),
contact_field=str(_pick(data, "contactField", "contact_field") or base.contact_field),
followup_field=str(_pick(data, "followupField", "followup_field") or base.followup_field),
update_field=str(_pick(data, "updateField", "update_field") or base.update_field),
update_resource=str(_pick(data, "updateResource", "update_resource") or base.update_resource),
update_project_field=str(
_pick(data, "updateProjectField", "update_project_field") or base.update_project_field
),
update_date_field=str(_pick(data, "updateDateField", "update_date_field") or base.update_date_field),
update_content_field=str(
_pick(data, "updateContentField", "update_content_field") or base.update_content_field
),
update_attachment_field=str(
_pick(data, "updateAttachmentField", "update_attachment_field") or base.update_attachment_field
),
)
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:
+23 -2162
View File
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,308 @@
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
@@ -0,0 +1,184 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,282 @@
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
@@ -0,0 +1,366 @@
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
@@ -0,0 +1,376 @@
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
@@ -0,0 +1,145 @@
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
@@ -0,0 +1,472 @@
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
+42 -611
View File
@@ -1,94 +1,51 @@
from __future__ import annotations
import argparse
import hmac
import os
import subprocess
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Annotated, Any
from typing import Any
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import TransportSecuritySettings
from pydantic import Field
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from .config import load_config
from .crm import CrmService
from .nb_client import NbAuthenticationError, NbClient, NbCommandError
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
DEFAULT_HTTP_HOST = "127.0.0.1"
DEFAULT_HTTP_PORT = 10087
DEFAULT_HTTP_PATH = "/mcp"
DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN"
ENABLE_CONFIG_TOOLS_ENV = "NOCOBASE_MCP_ENABLE_CONFIG_TOOLS"
ENABLE_RAW_DATA_ENV = "NOCOBASE_MCP_ENABLE_RAW_DATA"
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)
__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:
@@ -120,64 +77,6 @@ def _make_write_services(
return CrmService(config, client), ProjectService(config.project, client, http_client), http_client
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}}
def create_server(
*,
crm_service: CrmService | None = None,
@@ -191,10 +90,11 @@ def create_server(
allowed_origins: list[str] | None = None,
) -> Any:
if FastMCP is None:
raise RuntimeError("Missing MCP SDK. Install dependencies with: uv sync")
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(
@@ -209,486 +109,17 @@ def create_server(
transport_security=transport_security,
)
@server.tool()
def crm_health() -> dict[str, Any]:
"""Check NocoBase CLI reachability and the configured CRM collection mapping."""
return _safe_tool_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_tool_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_tool_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_tool_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_tool_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_tool_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_tool_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_tool_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_tool_result(
lambda: service.team_crm_overview(
team,
date_from=date_from,
date_to=date_to,
include_member_breakdown=include_member_breakdown,
)
)
if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
@server.tool()
def crm_config_template() -> dict[str, Any]:
"""Infer a config.json template from collection field metadata without reading business records."""
return _safe_tool_result(service.crm_config_template)
@server.tool()
def list_customers(
query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None,
status: Annotated[str | None, Field(description="Optional exact customer status.")] = None,
owner: Annotated[
str | None, Field(description="Optional customer owner name, username, email, or English name.")
] = None,
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_tool_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_tool_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_tool_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_tool_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,
),
)
@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_tool_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_tool_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_tool_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_tool_result(
lambda: service.list_stale_customers(
team=team,
owner=owner,
inactive_days=inactive_days,
as_of=as_of,
limit=limit,
offset=offset,
)
)
if not readonly:
@server.tool()
def create_project(
name: Annotated[str, Field(description="Project name. It must not be blank.")],
customer_id: Annotated[Any | None, Field(description="Optional NocoBase customer ID.")] = None,
inquiry_date: Annotated[
str | None, Field(description="Optional project inquiry date in YYYY-MM-DD format.")
] = None,
progress: Annotated[
str | None, Field(description="Optional configured Chinese project progress label.")
] = None,
applications: Annotated[str | None, Field(description="Optional project applications.")] = None,
part_numbers: Annotated[str | None, Field(description="Optional project part numbers.")] = None,
period_usage: Annotated[str | None, Field(description="Optional project period usage.")] = None,
details: Annotated[str | None, Field(description="Optional project details.")] = None,
attachment_ids: Annotated[
list[Any] | None, Field(description="Optional uploaded attachment IDs to associate.")
] = None,
) -> dict[str, Any]:
"""Create one fixed-schema NocoBase project and optionally associate uploaded attachments."""
assert project_service is not None
return _safe_tool_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_tool_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,
)
)
return server
def _read_token_from_env(env_name: str) -> str:
token = os.environ.get(env_name, "").strip()
if not token:
raise RuntimeError(f"{env_name} must be set for HTTP MCP transport")
return token
def create_http_app(server: Any, token: str, 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,
register_core_tools(
server,
service,
_safe_tool_result,
enable_config_tools=os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1",
)
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
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(
+150
View File
@@ -0,0 +1,150 @@
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
@@ -0,0 +1,139 @@
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
@@ -0,0 +1,80 @@
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
@@ -0,0 +1,161 @@
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
@@ -0,0 +1,85 @@
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
@@ -0,0 +1,84 @@
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",
]
+1 -1
View File
@@ -6,7 +6,7 @@ requires-python = ">=3.12"
dependencies = [
"cryptography>=50,<51",
"httpx>=0.27,<1",
"mcp[cli]>=1.27,<2",
"mcp>=1.27,<2",
"python-multipart>=0.0.9,<1",
]
+45 -583
View File
@@ -1,589 +1,51 @@
#!/usr/bin/env python3
"""Deterministic helpers for the agent-guided local installation flow.
This script deliberately does not clone the repository, install global tools,
authenticate to NocoBase, or edit an AI client's configuration. Those actions
need environment-specific judgment or direct user participation and are
governed by INSTALL.md.
"""
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
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,
)
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 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 _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
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
command = [uv_bin, "sync", "--locked", "--python", f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"]
try:
result = runner(command, cwd=str(root), 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",
}
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 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"],
}
def render_client_config(
project_root: str | Path | None = None,
*,
enable_writes: bool = False,
which: Which = shutil.which,
) -> dict[str, Any]:
root = _project_root(project_root)
uv_bin = which("uv") or "uv"
nb_bin = which("nb") or "nb"
if os.path.isabs(uv_bin):
uv_bin = os.path.abspath(uv_bin)
if os.path.isabs(nb_bin):
nb_bin = os.path.abspath(nb_bin)
args = [
"--directory",
str(root),
"run",
"--no-sync",
"nocobase-crm-mcp",
"--config",
str(root / "config.json"),
]
if enable_writes:
args.append("--enable-writes")
return {
"mcpServers": {
SERVER_NAME: {
"command": uv_bin,
"args": args,
"env": {"NOCOBASE_MCP_NB_BIN": nb_bin},
}
}
}
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"],
[
uv_bin,
"run",
"--no-sync",
"python",
"-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.",
],
}
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']}")
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)
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 == "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}")
__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__":
+17
View File
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,230 @@
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
@@ -0,0 +1,107 @@
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
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,83 @@
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
@@ -0,0 +1,107 @@
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
@@ -0,0 +1,212 @@
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"
+91 -563
View File
@@ -1,570 +1,98 @@
#!/usr/bin/env python3
"""Manage the user-level HTTP NocoBase CRM MCP systemd service."""
from __future__ import annotations
import argparse
import os
import secrets
import shlex
import shutil
import socket
import subprocess
import time
import urllib.error
import urllib.request
from collections.abc import Sequence
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",
}
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,
)
@dataclass(frozen=True)
class ServicePaths:
project_root: Path
unit_path: Path
env_path: Path
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
return secrets.token_urlsafe(num_bytes)
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 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 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) -> tuple[str, str, int, str, str, str, str]:
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 token, host, port, path, nb_env, config_path, nb_bin
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
)
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:
token, host, port, path, _nb_env, _config_path, _nb_bin = saved_service_config(project_root)
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 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,
)
token, host, port, path, nb_env, config_path, _saved_nb_bin = saved_service_config(project_root)
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
nb_env=nb_env,
config_path=config_path,
nb_bin=nb_bin,
)
write_service_unit(paths, host=host, port=port, path=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", port, path, token=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 host in {"0.0.0.0", "::"} else host
return render_remote_config(hosts=[display_host], token=token, port=port, path=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)
token, host, port, path, nb_env, _config_path, nb_bin = saved_service_config(project_root)
display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
lines.append(f"MCP URL: http://{display_host}:{port}{path}")
lines.append(f"nb binary: {nb_bin}")
lines.append(f"NocoBase CLI env: {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", port, path, token=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"
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__ = [
"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__":
+89
View File
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,264 @@
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
@@ -0,0 +1,354 @@
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",
]
+109 -3
View File
@@ -52,6 +52,88 @@ class AgentInstallTests(unittest.TestCase):
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 = []
@@ -73,7 +155,10 @@ class AgentInstallTests(unittest.TestCase):
self.assertTrue(first["configCreated"])
self.assertFalse(second["configCreated"])
self.assertEqual(preserved["env"], "custom-env")
self.assertEqual(calls[0][0], ["/opt/bin/uv", "sync", "--locked", "--python", "3.12"])
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:
@@ -227,12 +312,21 @@ class AgentInstallTests(unittest.TestCase):
writable = agent_install.render_client_config(root, enable_writes=True, which=fake_which)
server = config["mcpServers"]["nocobase-crm"]
self.assertEqual(server["command"], "/opt/bin/uv")
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()), server["args"])
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 = []
@@ -254,6 +348,14 @@ class AgentInstallTests(unittest.TestCase):
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"]))
@@ -269,6 +371,10 @@ class AgentInstallTests(unittest.TestCase):
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"])
+11
View File
@@ -46,6 +46,17 @@ class ConfigTests(unittest.TestCase):
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"
+64 -39
View File
@@ -12,6 +12,7 @@ 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):
@@ -106,7 +107,7 @@ class McpServiceConfigTests(unittest.TestCase):
with (
patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "nb-custom"}),
patch.object(
manage_mcp_service.shutil,
mcp_service_runtime.shutil,
"which",
return_value="/usr/local/bin/nb-custom",
),
@@ -115,13 +116,13 @@ class McpServiceConfigTests(unittest.TestCase):
with (
patch.dict("os.environ", {}, clear=True),
patch.object(manage_mcp_service.shutil, "which", return_value="/usr/local/bin/nb"),
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(manage_mcp_service.shutil, "which", return_value=None),
patch.object(mcp_service_runtime.shutil, "which", return_value=None),
):
with self.assertRaisesRegex(RuntimeError, "Could not find nb"):
manage_mcp_service.find_nb_path()
@@ -140,6 +141,30 @@ class McpServiceConfigTests(unittest.TestCase):
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"
@@ -199,10 +224,10 @@ class McpServiceConfigTests(unittest.TestCase):
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
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)
@@ -229,10 +254,10 @@ class McpServiceConfigTests(unittest.TestCase):
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
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)
@@ -261,12 +286,12 @@ class McpServiceConfigTests(unittest.TestCase):
)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
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(
manage_mcp_service,
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
@@ -299,13 +324,13 @@ class McpServiceConfigTests(unittest.TestCase):
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="first-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
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(
manage_mcp_service,
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
@@ -340,13 +365,13 @@ class McpServiceConfigTests(unittest.TestCase):
)
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl"),
patch.object(manage_mcp_service.time, "sleep") as sleep,
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(
manage_mcp_service,
mcp_service_runtime,
"probe_http_service",
side_effect=[
{"reachable": False, "error": "connection refused"},
@@ -378,9 +403,9 @@ class McpServiceConfigTests(unittest.TestCase):
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service, "find_nb_path", return_value=Path("/opt/node/bin/nb")),
patch.object(manage_mcp_service, "run_systemctl", side_effect=fake_systemctl),
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)
@@ -394,8 +419,8 @@ class McpServiceConfigTests(unittest.TestCase):
"saved-token",
)
with (
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "service_state", return_value="inactive", create=True),
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)
@@ -414,10 +439,10 @@ class McpServiceConfigTests(unittest.TestCase):
path="/mcp",
)
with (
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service, "service_state", return_value="active", create=True),
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(
manage_mcp_service,
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": True, "status": 401},
create=True,
@@ -444,10 +469,10 @@ class McpServiceConfigTests(unittest.TestCase):
env_file.write("NOCOBASE_MCP_API_TOKEN=never-print-this\n")
with (
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
patch.object(manage_mcp_service, "service_state", return_value="inactive"),
patch.object(mcp_service_runtime, "detect_remote_hosts", return_value=[]),
patch.object(mcp_service_runtime, "service_state", return_value="inactive"),
patch.object(
manage_mcp_service,
mcp_service_runtime,
"probe_http_service",
return_value={"reachable": False, "error": "stopped"},
),
Generated
+2 -41
View File
@@ -10,15 +10,6 @@ resolution-markers = [
"python_full_version < '3.14' and sys_platform != 'win32'",
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -639,12 +630,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
]
[package.optional-dependencies]
cli = [
{ name = "python-dotenv" },
{ name = "typer" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -765,7 +750,7 @@ source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
{ name = "mcp" },
{ name = "python-multipart" },
]
@@ -780,7 +765,7 @@ dev = [
requires-dist = [
{ name = "cryptography", specifier = ">=50,<51" },
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.27,<2" },
{ name = "mcp", specifier = ">=1.27,<2" },
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
]
@@ -1239,15 +1224,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
@@ -1337,21 +1313,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]]
name = "typer"
version = "0.26.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"