Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f3d3ad5fc | ||
|
|
6b5b3a413a | ||
|
|
7cc521938f | ||
|
|
c7e62d1d6d | ||
|
|
4c53f90947 | ||
|
|
5c2cbac5b1 | ||
|
|
c40e456d7c |
@@ -3,14 +3,18 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+176
-56
@@ -1,117 +1,237 @@
|
||||
# 安装 NocoBase CRM MCP
|
||||
# 安装或更新 NocoBase CRM MCP
|
||||
|
||||
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在隐藏提示中输入自己的 NocoBase Authorize Key。
|
||||
这是一份供终端型 AI Agent 执行的通用安装与更新协议,不依赖任何特定 Agent 或 MCP 客户端。用户每次都可以提供同一个链接:Agent 必须先识别是首次安装、更新还是已经为最新版,再执行对应流程。只有确实需要认证时,用户才在浏览器中登录自己的 NocoBase 账号完成授权。
|
||||
|
||||
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、取得 NocoBase URL、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。
|
||||
统一入口:`https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md`。执行 Agent 必须按下文将其固定到任务开始时的完整 Commit SHA。
|
||||
|
||||
如果你是执行 Agent,请直接完成流程,不要让用户逐条尝试命令。只有在确认系统工具变更或确实等待用户在 NocoBase 网页登录授权时才中断用户;不得仅因更新而要求用户重新登录。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 下载本文件所在的同一固定 Release,不使用默认分支;
|
||||
- 从 Gitea `main` 解析任务开始时的最新完整 Commit SHA,并将本次任务的文档、源码和构建全部固定到该 Commit;
|
||||
- 只连接固定的 NocoBase API `https://justconn.top/api`;
|
||||
- MCP 在用户本机以 stdio 运行,默认只读;
|
||||
- 认证属于当前用户自己的个人 Authorize Key 和业务角色;
|
||||
- 通过 NocoBase 原生 OAuth 2.1 认证当前用户,权限仍跟随本人的业务角色;
|
||||
- 当前 MCP 客户端已注册 `nocobase-crm`;
|
||||
- 首次安装、更新或已是最新的判断有明确依据,更新保留原有 CRM 配置、个人凭据和只读/写入模式;
|
||||
- `crm_health` 返回 `ok=true`。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- 不索取账号密码,不使用共享服务账号或管理员 Key。
|
||||
- 不让用户把 Key 发到聊天、命令参数、环境变量、剪贴板日志或 MCP 配置。
|
||||
- Key 只能由用户输入到安装程序的本机隐藏 TTY 提示。
|
||||
- 不索取账号密码、OAuth Token 或 Authorize Key,不使用共享服务账号或管理员身份。
|
||||
- 用户只能在 `https://justconn.top` 的 NocoBase 页面输入账号信息并确认授权。
|
||||
- OAuth Token 不得进入聊天、命令参数、环境变量、CRM 配置、剪贴板日志或 MCP 客户端配置。
|
||||
- 不使用 `sudo`,不执行 `curl | sh` 或 `wget | sh`。
|
||||
- 不静默安装 Git、Node.js、包管理器或其他系统工具。
|
||||
- 解析 `main` 后只使用带完整 Commit SHA 的不可变 URL;一次任务中不得再次读取移动中的 `main` 文件。
|
||||
- 最新 Commit 无法下载、检查、测试或构建时必须停止,不得静默退回旧 Release、旧 Commit 或其他来源。
|
||||
- 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。
|
||||
- 更新不得读取、复制、覆盖或删除个人凭据,不得覆盖现有 CRM `config.json`,也不得擅自改变 `--enable-writes` 模式。
|
||||
- 默认不启用写入、原始数据、后台服务或网络监听。
|
||||
- 不询问或接受其他 NocoBase URL,也不允许通过参数、配置或凭证文件覆盖固定 API 地址。
|
||||
|
||||
## 安装流程
|
||||
## 安装或更新流程
|
||||
|
||||
### 1. 确认输入与 Release 权限
|
||||
### 1. 判断首次安装、更新或已有最新版
|
||||
|
||||
取得:
|
||||
先检查当前 MCP 客户端配置中的 `nocobase-crm` 条目、其启动命令和参数,以及推荐用户级安装目录中是否已有可执行文件。若存在 `<install-prefix>/install-receipt.json`,可以读取其中不含秘密的安装记录;只有 `schemaVersion=1`、仓库 URL 完全匹配、`commitSha` 为 40 位十六进制、`tarballSha256` 为 64 位十六进制且其余字段类型正确时才视为有效。无效或缺失的记录不能作为跳过构建的依据。不得打开、解析或输出凭证文件内容,只能在必要时判断凭证文件是否存在。
|
||||
|
||||
- NocoBase 应用 URL,例如 `https://nocobase.example`;
|
||||
- 当前 Agent/MCP 客户端及其配置路径;
|
||||
- 普通用户应用数据目录。
|
||||
按以下规则确定初始状态:
|
||||
|
||||
从本文件 URL 解析固定 Release Tag。读取同一 Release 的资产列表,只接受:
|
||||
- **首次安装:** 没有可用的 `nocobase-crm` 配置和已安装命令。默认只读,除非用户在本次任务中明确要求启用固定项目写入。
|
||||
- **更新:** 已有配置和可执行命令。沿用其安装前缀、CRM 配置路径及 `--enable-writes` 状态,除非用户明确要求变更;个人凭据始终原地保留。
|
||||
- **修复旧安装:** 只找到配置、命令或安装目录的一部分。说明检测到的不一致,然后按更新方式修复缺失部分,不删除仍可用的配置或凭据。
|
||||
|
||||
- `donaldttt-nocobase-crm-mcp-<version>.tgz`;
|
||||
- `SHA256SUMS`。
|
||||
记录检测结果、现有安装目录、当前版本、原读写模式,以及安装记录中的旧完整 Commit SHA(若存在)。旧版本没有安装记录不是错误,第一次更新时正常重建并补写记录。不得仅凭 npm 包版本判断 Commit 是否相同,因为多个 Commit 可以使用同一包版本。
|
||||
|
||||
仓库为 Private 时,必须使用当前用户已经授权的 GitHub 连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户 GitHub Token 到聊天。
|
||||
### 2. 解析并固定最新 Commit
|
||||
|
||||
### 2. 一次性说明本机变更
|
||||
确定当前 Agent/MCP 客户端及其配置路径,以及普通用户应用数据目录。NocoBase API 已固定为 `https://justconn.top/api`,不需要向用户询问地址。
|
||||
|
||||
检查 `node --version` 和 `npm --version`。要求 Node.js `>=20.11`。
|
||||
安装源固定为:
|
||||
|
||||
如果 Node.js 缺失或版本过低,向用户说明需要安装或升级 Node.js、官方来源、目标位置和影响,取得明确同意后再使用操作系统的正规安装方式。不能因为 npm 不存在就直接尝试全局安装。
|
||||
- 仓库:`https://git.justconn.top/gitea-admin/nocobase-mcp`
|
||||
- 分支:`main`
|
||||
- 分支 API:`https://git.justconn.top/api/v1/repos/gitea-admin/nocobase-mcp/branches/main`
|
||||
|
||||
正常安装只会留下:
|
||||
通过当前用户已经授权的 Gitea API/连接读取分支 API,只解析一次 `commit.id`,并验证它是 40 位十六进制完整 SHA。记录为 `<commit-sha>` 后,本次任务即使发现 `main` 又有新提交,也继续完成这个已固定快照;下一次安装或更新才重新解析最新提交。
|
||||
|
||||
立即从以下不可变地址重新读取安装协议,后续不再依赖最初的 `main` 页面内容:
|
||||
|
||||
```text
|
||||
https://git.justconn.top/gitea-admin/nocobase-mcp/raw/commit/<commit-sha>/INSTALL.md
|
||||
```
|
||||
|
||||
仓库访问受限时必须使用当前用户已有的授权连接;无权访问时停止,不得索取或接收用户在聊天中发送的 Gitea 访问令牌。
|
||||
|
||||
若来源为本仓库的有效安装记录中的旧 SHA 等于 `<commit-sha>`,还必须确认已安装命令的 `--version` 与记录一致、`--help` 正常、MCP 配置指向存在的入口。全部一致时将操作判定为**已有最新版**:跳过源码下载、构建和 npm 安装,直接进入 MCP 配置核对与 `crm_health` 验收。任何一项不一致都按更新/修复继续,不能因为包版本看起来相同而跳过。
|
||||
|
||||
### 3. 一次性说明本机变更
|
||||
|
||||
向用户一次性说明本次判定为首次安装、更新、修复还是已有最新版,以及目标 Commit、安装目录、将保留的内容和预计变更。检查 `node --version` 和 `npm --version`;需要构建或安装时要求 Node.js `>=20.11`。
|
||||
|
||||
如果 Node.js 缺失或版本过低,向用户说明需要安装或升级 Node.js、官方来源、目标位置和影响,取得明确同意后再使用操作系统的正规安装方式。不能因为 npm 不存在就直接尝试全局安装。还需确认 npm 能读取锁定依赖;不得改用未锁定的依赖版本。
|
||||
|
||||
首次安装或更新只会留下:
|
||||
|
||||
- 应用数据目录中的 npm 包;
|
||||
- 不含 Key 的 CRM `config.json`;
|
||||
- 不含凭据的 CRM `config.json`;
|
||||
- 权限受限的本机凭证文件;
|
||||
- MCP 客户端配置及其备份。
|
||||
|
||||
不会安装 Git、Python、uv、`nb CLI`、后台服务或本机监听端口。
|
||||
源码快照、构建期 `node_modules` 和本地 tarball 只放在新建的临时目录,安装完成后不作为持久文件保留。不会安装 Git、Python、uv、`nb CLI`、后台服务或本机监听端口。
|
||||
|
||||
### 3. 下载并验证固定发布物
|
||||
更新或修复前备份 MCP 客户端配置和非秘密 CRM `config.json`。凭据位于包目录之外,只原地保留,不为更新另行读取或复制。若判定为已有最新版且配置无需修改,不要为了形式上的更新创建多余备份或重写文件。
|
||||
|
||||
通过已授权的 GitHub API/连接下载两个 Release 资产到新建的临时目录。验证 tarball 的 SHA-256 与 `SHA256SUMS` 完全一致;不一致立即停止并删除未使用的下载文件。
|
||||
### 4. 下载、验证并构建固定 Commit
|
||||
|
||||
选择用户级安装前缀,例如:
|
||||
在新建的临时目录中下载 `<commit-sha>` 的源码归档,不下载 Release 资产,也不从移动的 `main` URL 下载源码。Windows 使用 `.zip`,macOS/Linux 使用 `.tar.gz`;两种归档都必须包含同一个已解析的完整 Commit SHA:
|
||||
|
||||
- Windows:`%LOCALAPPDATA%\NocoBaseMCP\app`
|
||||
- macOS:`~/Library/Application Support/NocoBaseMCP/app`
|
||||
```text
|
||||
Windows: https://git.justconn.top/gitea-admin/nocobase-mcp/archive/<commit-sha>.zip
|
||||
macOS/Linux: https://git.justconn.top/gitea-admin/nocobase-mcp/archive/<commit-sha>.tar.gz
|
||||
```
|
||||
|
||||
不要让用户选择版本,也不要因为操作系统不同而改用 Release。每次新的安装或更新任务都重新解析当时 `main` 的最新 Commit,随后只下载并使用该 SHA。只有已按第 2 步确认安装记录与最新 SHA 相同且现有入口完整时,才跳过本节。
|
||||
|
||||
下载后记录归档 SHA-256。解包前拒绝绝对路径、`..` 路径穿越或逃出临时目录的链接;源码根目录必须包含 `package.json`、`package-lock.json`、`install-manifest.json`、`INSTALL.md` 和 `src/`。
|
||||
|
||||
安全解包实现必须在写入文件前完成兼容性预检,不要把解包工具的 API 差异误报为源码构建失败,也不要为解包安装或升级 Python:
|
||||
|
||||
- Python 不是安装前置条件。若 Agent 选择使用本机已有的 Python `tarfile`,不得无条件调用仅较新 Python 才支持的 `TarFile.extractall(..., filter="data")`;必须先用 `inspect.signature` 检查 `filter` 参数是否存在。
|
||||
- 支持 `filter` 时仍需使用 `data` 过滤器。旧版 Python 不支持时,必须在解包前验证完整成员列表,拒绝绝对路径、盘符或 UNC 路径、任何 `..` 分段、重复目标、符号链接、硬链接、设备文件和 FIFO;随后只逐项创建目录,并通过 `extractfile()` 将普通文件复制到已确认位于新建临时根目录内的目标。不得直接调用不带过滤器的 `extractall()`。
|
||||
- Windows `.zip` 也必须先验证所有条目并拒绝上述路径、链接和特殊文件,再逐项解包。解包目录必须是本次任务新建且仅当前用户可写的临时目录。
|
||||
- 这种预先选择兼容实现仍是在处理同一个已校验哈希的归档和同一个固定 Commit,不属于版本回退,不需要重新解析 `main`、重新下载归档或中断用户。只有当前环境没有任何可安全验证并解包归档的现有工具时才停止。
|
||||
|
||||
确认归档中的 `install-manifest.json` 声明 `distribution.branch` 为 `main`、`distribution.strategy` 为 `resolve-latest-commit-then-pin`、`installation.supportsInPlaceUpdate=true`、`installation.sameCommitBuildSkip=true`,并且 `installation.installReceipt` 声明 `schemaVersion=1`、`containsSecrets=false`。随后只在这个临时源码目录运行:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run check
|
||||
npm test
|
||||
npm run build
|
||||
npm pack --pack-destination <temporary-output-directory>
|
||||
```
|
||||
|
||||
任何一步失败都停止并报告 `<commit-sha>` 与失败步骤,不得改装旧 Release。记录本地生成 npm tarball 的 SHA-256;安装结束时只报告哈希,不发送或上传 tarball。
|
||||
|
||||
首次安装时选择用户级安装前缀;更新或修复时沿用检测到的原前缀。例如:
|
||||
|
||||
- Windows:`%LOCALAPPDATA%\nocobase-crm-mcp\app`
|
||||
- macOS:`~/Library/Application Support/nocobase-crm-mcp/app`
|
||||
- Linux:`${XDG_DATA_HOME:-~/.local/share}/nocobase-crm-mcp/app`
|
||||
|
||||
执行隔离安装,不使用全局目录:
|
||||
|
||||
```bash
|
||||
npm install --prefix <install-prefix> --omit=dev <verified-release.tgz>
|
||||
npm install --prefix <install-prefix> --omit=dev <locally-built-commit.tgz>
|
||||
```
|
||||
|
||||
定位安装后的命令:POSIX 为 `<install-prefix>/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。
|
||||
`config.json` 和个人凭据必须位于应用数据目录而不是 npm 包目录内;不得在安装前清空 `<install-prefix>`,也不得通过删除整个应用数据目录来“更新”。如果安装命令失败,报告失败并保留可恢复信息,不得删除原配置或凭据。
|
||||
|
||||
### 4. 个人 Key 设置
|
||||
定位安装后的命令:POSIX 为 `<install-prefix>/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。运行 `--version` 和 `--help`,确认版本等于源码 `package.json`,并且帮助中包含本 Commit 安装协议使用的 `setup --no-browser` 选项;不一致时停止,不得继续注册旧二进制。
|
||||
|
||||
先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的个人 Authorize Key。不要让用户把 Key 发给 Agent。
|
||||
### 5. 注册或核对 MCP
|
||||
|
||||
在用户可直接控制的本机终端运行:
|
||||
首次安装、更新或修复后运行;已有最新版时也可用它核对当前配置:
|
||||
|
||||
```bash
|
||||
<installed-command> setup --api-base-url <nocobase-url> --json
|
||||
<installed-command> client-config [--config <existing-config-path>] [--enable-writes] --json
|
||||
```
|
||||
|
||||
安装程序会先验证安全 TTY,再显示隐藏 Key 提示;没有安全 TTY 时会在写文件前停止。Key 通过 `roles:check` 验证成功后才保存,并立即执行 `crm_health`。Key 不会出现在返回 JSON、命令参数、MCP 配置或日志中。
|
||||
首次安装可省略 `--config` 使用默认位置,并默认省略 `--enable-writes`。更新或修复必须把原 MCP 条目中的 CRM 配置路径传给 `--config`,并保持原 `--enable-writes` 状态;只有用户本次明确要求改变时才增删该参数。
|
||||
|
||||
POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。不要复制其他用户的凭证文件;每个操作系统用户都必须单独设置自己的 Key。
|
||||
该命令会在需要时从包内模板准备默认 `config.json`,不要求凭据,也不会发起 OAuth。读取返回的 `clientConfig`:
|
||||
|
||||
### 5. 注册并验证 MCP
|
||||
1. 展示客户端配置路径和目标 `nocobase-crm` 条目;
|
||||
2. 仅在条目需要修改时创建可恢复备份;
|
||||
3. 仅当条目有变化时结构化合并该 server,保留所有其他配置;
|
||||
4. 重载或重启 MCP 客户端,并在客户端要求时让用户信任该连接器。
|
||||
|
||||
读取 setup 返回的 `clientConfig`:
|
||||
没有凭据时 stdio MCP 仍应正常启动并响应 `tools/list`。此时业务工具会返回结构化的 `nocobase_auth_failed`,而不是导致整个 MCP 进程退出。
|
||||
|
||||
1. 展示客户端配置路径和新增的 `nocobase-crm` 条目;
|
||||
2. 创建可恢复备份;
|
||||
3. 结构化合并该 server,保留所有其他配置;
|
||||
4. 重载或重启 MCP 客户端;
|
||||
5. 调用 `crm_health`,要求 `ok=true`。
|
||||
### 6. 先验收现有登录,必要时再把链接交给用户
|
||||
|
||||
最终只报告 Release、安装目录、配置备份位置和只读/写入模式,不显示任何 Key。
|
||||
注册或重载完成后,**先调用 `crm_health`**。若返回 `ok=true`,说明现有登录仍然有效,直接完成验收;不得为了首次安装或更新流程的形式完整而调用登录工具、生成新链接或要求用户再次授权。
|
||||
|
||||
只有 `crm_health` 明确返回 `nocobase_auth_failed` 时才进入登录:首次安装且没有可用凭据时调用 `nocobase_login({})`;更新/修复中已有凭据被 NocoBase 拒绝时调用 `nocobase_login({"force": true})`。如果 `crm_health` 返回 ACL、配置、网络或超时错误,处理对应错误,不能用重新登录掩盖它。
|
||||
|
||||
首次安装且没有可用凭据时调用:
|
||||
|
||||
```text
|
||||
nocobase_login({})
|
||||
```
|
||||
|
||||
现有凭据已被 NocoBase 拒绝时改为调用:
|
||||
|
||||
```text
|
||||
nocobase_login({"force": true})
|
||||
```
|
||||
|
||||
该工具通过 NocoBase OAuth 发现接口动态注册本机公共客户端,请求 `api offline_access`,并立即返回:
|
||||
|
||||
- `login_url`:只指向 `https://justconn.top` 的完整授权链接;
|
||||
- `verification_uri_complete`:与 `login_url` 相同的 OAuth 标准语义别名;
|
||||
- `user_code`:供用户在页面核对的确认码;
|
||||
- `expires_in_seconds`:剩余有效时间;
|
||||
- `browser_opened`:桌面浏览器是否已成功自动打开。
|
||||
|
||||
返回值还必须明确标记 `authorization_type=oauth_device_user_verification`、`intended_recipient=requesting_user`、`delivery_channel=current_private_conversation`、`shareable_with_requesting_user=true`、`private_conversation_required=true`、`public_sharing_allowed=false`、`contains_bearer_credentials=false`、`contains_device_code=false` 和 `remote_desktop_required=false`。这些字段区分“应交给发起用户的短期验证信息”和绝不能离开本机的 OAuth 凭据。
|
||||
|
||||
无论 `browser_opened` 是真是假,都应立即把 `login_url`、`user_code` 和有效期原样交给发起登录的用户。无界面或远程环境必须通过当前一对一私聊发送;这是 OAuth Device Flow 规定的用户验证交接,不是密码、API Key、Bearer Token 或内部 `device_code`,不得以“链接包含一次性凭据”为由拒绝发送,也不得要求用户操作服务器远程桌面。当前渠道若是群聊或公开频道,先让用户转到私聊,不得把信息发给第三方。用户只在 NocoBase 网页输入账号信息、核对确认码并点击授权。随后调用 `nocobase_login_status({})`;授权仍在等待时它会安全地重复同一链接,成功时返回 `status=authenticated`。不要高频轮询;可在用户确认完成后或按合理间隔查询。
|
||||
|
||||
取得 `authenticated` 后再次调用 `crm_health`,要求 `ok=true`。不得换用共享高权限 Token 或 Key。
|
||||
|
||||
这一流程同时适用于桌面 GUI、Hermes Agent、OpenClaw 和远程无界面服务器:桌面环境会尝试自动打开浏览器;远程环境直接把同一个 HTTPS 链接经当前一对一 Messenger 或私聊交给用户,用户可在自己的手机或电脑打开。待授权会话会写入凭证旁边的短期私有文件;即使 MCP stdio 进程在两条 Messenger 消息之间退出或重启,下一次 `nocobase_login_status` 也必须恢复同一个链接并继续兑换,不得生成新链接让用户重复授权。该文件包含不向 Agent 暴露的 OAuth 设备码,POSIX 权限必须为 `0600`,且在成功、取消或过期后立即删除。正式 Token 的验证和保存仍由 MCP 完成;Agent 只能看到应交给发起用户的链接、确认码和状态。一个 MCP 凭据路径只能服务一个受信任的用户身份;多用户必须使用相互隔离的 Agent/MCP 实例和凭据路径。
|
||||
|
||||
设备授权约十分钟后失效。密码和浏览器会话始终留在 NocoBase,程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期。POSIX 凭证目录使用 `0700`,正式凭证文件和短期待授权文件都使用 `0600`;Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 和设备码不会出现在工具返回、命令参数、环境变量、MCP 配置或日志中。
|
||||
|
||||
如果某个 MCP 客户端在登录前完全无法加载自定义连接器,才使用 CLI 备用入口:
|
||||
|
||||
```bash
|
||||
<installed-command> setup --no-browser --json
|
||||
```
|
||||
|
||||
本机桌面终端也可省略 `--no-browser` 以尝试自动打开网页。CLI 会先验证或准备配置,再开始 OAuth,避免用户完成网页授权后才因配置路径不存在而丢失结果。使用 `--json` 时,CLI 会在 stderr 输出 `event=authorization_required` 的结构化事件和上述交付标记。Agent 必须持续读取该进程的输出、把链接和确认码原样发到当前私聊,并让进程继续轮询;不得关闭进程或要求远程桌面。
|
||||
|
||||
只有在 OAuth 暂时不可用、管理员明确要求且用户拥有个人 Authorize Key 时,才使用:
|
||||
|
||||
```bash
|
||||
<installed-command> setup --manual-key --json
|
||||
```
|
||||
|
||||
该备用模式必须在用户可直接控制的安全 TTY 中隐藏输入 Key;仍不得让用户把 Key 发给 Agent。
|
||||
|
||||
在 `crm_health` 成功后,首次安装、更新或修复必须以原子替换方式写入 `<install-prefix>/install-receipt.json`,POSIX 权限使用 `0600`。使用以下固定字段,把占位值替换为本次实际结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"repository": "https://git.justconn.top/gitea-admin/nocobase-mcp",
|
||||
"commitSha": "<40-character-commit-sha>",
|
||||
"packageVersion": "<package-version>",
|
||||
"tarballSha256": "<64-character-sha256>",
|
||||
"installedAt": "<ISO-8601-UTC-time>",
|
||||
"writesEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
`writesEnabled` 必须按最终实际模式写成 `true` 或 `false`,不能始终照抄示例。该记录不得包含 Token、Key、设备码或其他凭据。已有最新版且记录完整时不重写该文件。
|
||||
|
||||
最终报告本次结果是“已安装”“已更新”“已修复”还是“已是最新”,并报告旧/新完整 Commit SHA(旧 SHA 可用时)、源码版本、安装目录、配置备份位置、认证是否沿用以及只读/写入模式。只有本次实际构建了 tarball 才报告新 SHA-256;已有最新版不得谎称重新构建。任何情况下都不显示 Token、Key 或设备码。
|
||||
|
||||
## 可选写入
|
||||
|
||||
未明确选择时保持只读。只有用户明确要求项目创建/更新,并且管理员确认该用户角色具有对应 NocoBase ACL 时,才在 setup 以及生成的启动参数中加入 `--enable-writes`。这只增加 `create_project` 和 `update_project`;不增加删除、任意集合访问或附件文件上传接口。
|
||||
未明确选择时保持只读。只有用户明确要求项目创建/更新,并且管理员确认该用户角色具有对应 NocoBase ACL 时,才在 `client-config`(或 CLI 备用 `setup`)以及生成的启动参数中加入 `--enable-writes`。这只增加 `create_project` 和 `update_project`;不增加删除、任意集合访问或附件文件上传接口。
|
||||
|
||||
## 故障处理
|
||||
|
||||
- `nocobase_auth_failed`:让用户撤销失效 Key,重新运行同一个 setup 命令并在隐藏提示输入新 Key。
|
||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限 Key。
|
||||
- 浏览器未打开:把 `nocobase_login` 返回的 `login_url` 和 `user_code` 发到发起用户的当前私聊,让用户在自己的手机或电脑打开;不需要重启 MCP 或使用服务器远程桌面。
|
||||
- 授权超时或被取消:再次调用 `nocobase_login` 生成新链接并重新核对确认码。
|
||||
- `nocobase_auth_failed`:调用 `nocobase_login({"force": true})` 重新授权;不要换用共享高权限 Token 或 Key。
|
||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限身份。
|
||||
- Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。
|
||||
- Private Release 无权访问:报告分发权限问题,不退回 clone 默认分支。
|
||||
|
||||
## 用户可发送给 Agent 的最短指令
|
||||
|
||||
```text
|
||||
请按这个固定 Release 的协议帮我完成本机安装。默认只读,使用我自己的 NocoBase Authorize Key;只让我在本机隐藏提示中输入 Key,不要静默安装系统工具。完成前必须验证 crm_health:
|
||||
<固定 Release 的 INSTALL.md 链接>
|
||||
```
|
||||
- `main` 或 Commit 归档无权访问:报告 Gitea 权限问题,不索取聊天中的访问令牌,也不退回旧 Release。
|
||||
- 解包工具兼容性:在写入前改用本节规定的安全兼容实现,继续处理同一归档;不得先调用已知不受支持的 `tarfile filter` 参数,也不得因此重新解析 Commit、安装 Python 或回退版本。
|
||||
- 最新 Commit 的依赖安装、检查、测试或构建失败:报告 SHA 和原始失败步骤后停止;不得以“先装可用版本”为由降级。
|
||||
- 任务过程中 `main` 更新:不影响当前任务,继续完成开始时固定的 SHA;下次安装或更新再取得新 SHA。
|
||||
|
||||
@@ -1,34 +1,100 @@
|
||||
# NocoBase CRM MCP
|
||||
|
||||
本项目是在用户电脑本机运行的 NocoBase CRM MCP。每位用户使用自己的个人 Authorize Key,所有查询和写入继续由 NocoBase ACL、字段权限和行级权限裁决;不需要共享账号或远程 MCP 服务。
|
||||
在你的电脑上,让 AI 客户端安全地查询 NocoBase CRM。首次需要认证时通过浏览器登录 NocoBase;每位用户使用自己的身份,看到的数据范围与本人在 NocoBase 中的权限一致。
|
||||
|
||||
## 用户侧依赖
|
||||
## 安装或更新
|
||||
|
||||
正式 Release 是一个已打包的 npm tarball,内部包含 MCP SDK 和运行代码。用户电脑只需要:
|
||||
推荐把首次安装和后续更新都交给支持终端操作的 AI Agent 完成。同一段提示可以重复使用:Agent 会先检查本机状态,未安装时完成首次安装,已安装时保留个人配置和登录状态并更新到最新 Commit。你不需要克隆源码,也不需要逐条执行命令。
|
||||
|
||||
- Node.js 20.11 或更高版本(npm 随 Node.js 一起提供);
|
||||
- 能访问目标 NocoBase;
|
||||
- 用户自己的个人 Authorize Key。
|
||||
### 安装前准备
|
||||
|
||||
用户不再需要 Git、Python、uv、Python 包或 `nb CLI`。安装也不会全局安装本项目,而是放到用户自己的应用数据目录。
|
||||
- Node.js 20.11 或更高版本;
|
||||
- 能访问 `https://justconn.top`;
|
||||
- 你自己的 NocoBase 账号;
|
||||
- 能读取当前 Gitea 仓库 `main` 分支,并能访问 npm 软件源。
|
||||
|
||||
Private GitHub 仓库的 Release 只能由已经获得仓库或分发渠道权限的用户下载。若要实现面向任意用户的“一条链接安装”,需要另行提供用户 Agent 能认证访问的下载地址。
|
||||
如果 Node.js 尚未安装,Agent 会先说明需要进行的系统变更,并在获得你确认后再继续。
|
||||
|
||||
## 安装
|
||||
支持 Windows、macOS 和 Linux,三个平台都会使用任务开始时 `main` 的最新 Commit。
|
||||
|
||||
把对应 Release Tag 下的不可变 [`INSTALL.md`](INSTALL.md) 链接交给安装 Agent。该协议不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定客户端。Agent 会:
|
||||
不需要提前创建或复制 Authorize Key。MCP 注册或重载后,Agent 会先检查现有登录;只有确实需要认证时才调用登录工具并把 NocoBase 官方网页链接发给你。桌面环境通常也会自动打开该页面,你确认授权后 Agent 会自动继续验收。
|
||||
|
||||
1. 检查 Node.js,而不会静默安装系统工具;
|
||||
2. 下载并校验固定 Release 的 npm tarball;
|
||||
3. 安装到用户应用数据目录;
|
||||
4. 引导用户在本机隐藏提示中输入自己的 Authorize Key;
|
||||
5. 备份并结构化合并 MCP 客户端配置;
|
||||
6. 调用 `crm_health` 验证完成。
|
||||
### 最快方式
|
||||
|
||||
默认 stdio、只读、不监听端口。只有用户明确要求且管理员确认 ACL 时,才给启动参数增加 `--enable-writes`,这只增加固定字段的 `create_project` 和 `update_project`,不提供删除或任意资源工具。
|
||||
无论是第一次安装还是以后更新,都把下面整段直接复制给 Agent:
|
||||
|
||||
```text
|
||||
请按以下 INSTALL.md 帮我安装或更新 NocoBase CRM MCP。自动判断首次安装、更新、修复或已是最新,使用 main 当前最新 Commit,并严格执行文档中的安全与验收规则;已有安装时保留配置、凭据和读写模式。只有确实需要登录时,才把 NocoBase 官方网页链接和确认码发给我。无界面或远程环境也要发到当前私聊;它们不是密码、Token、Key 或 device_code,不要要求远程桌面。安装系统工具前先征得我同意。完成前确认 crm_health 返回 ok=true。
|
||||
https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md
|
||||
```
|
||||
|
||||
Agent 会固定任务开始时的最新 Commit,按本机状态执行安装、更新、修复或直接验收;已有安装会保留个人状态,相同 Commit 不会重复构建。注册后先检查 `crm_health`,只有认证确实缺失或失效时才发起登录。桌面环境通常自动打开浏览器;远程 Agent 会把同一授权链接发到当前对话,MCP 重启后也能继续该授权。
|
||||
|
||||
完整的安装、更新和验收规则见 [INSTALL.md](INSTALL.md)。该 `main` 链接是统一入口;Agent 必须先解析一次最新 Commit,然后重新读取该 Commit 中不可变的同一文件,不能在一次任务中混用不同提交。
|
||||
|
||||
## 重要注意事项
|
||||
|
||||
- **只在 NocoBase 网页登录:** 安装程序不会读取浏览器 Cookie,也不会接触你的密码;OAuth 令牌只保存到权限受限的个人凭据文件,不会进入聊天、命令参数、环境变量、CRM 配置、MCP 客户端配置或日志。
|
||||
- **默认只读:** 安装后默认只能查询 CRM,不会创建、更新或删除业务数据。
|
||||
- **权限跟随本人:** MCP 不会绕过 NocoBase 的角色、字段权限和行级权限,也不会使用共享管理员账号。
|
||||
- **远程登录不需要远程桌面:** Hermes Agent、OpenClaw 或无界面服务器会把短期 OAuth Device Flow 验证链接和确认码发到发起用户的当前私聊;它们不能发到群聊、公开频道或第三方,但不是密码、Token、Key 或内部 `device_code`。
|
||||
- **只连接公司环境:** 程序固定连接 `https://justconn.top/api`,不会要求你填写其他 NocoBase 地址。
|
||||
- **始终使用最新提交:** 每次安装或更新只解析一次 `main` 的最新 Commit SHA,随后固定该快照完成任务;不会因为旧 Release 可用而安装旧代码。
|
||||
- **更新保留个人状态:** 更新不会覆盖 CRM `config.json`、OAuth 凭据或现有只读/写入模式;已有登录有效时不会要求重新授权。
|
||||
- **安装在用户目录:** 不使用 `sudo`,不会全局安装本项目,也不需要 Git、Python、uv 或 `nb CLI`。源码和构建依赖只存在于临时目录,安装后仅保留打包运行文件。
|
||||
- **受限仓库需要授权:** 如果 Agent 无法读取 Gitea `main` 或对应 Commit 快照,请联系仓库管理员开通权限,不要把访问令牌发到聊天中。
|
||||
|
||||
## 安装后可以做什么
|
||||
|
||||
你可以直接让 AI:
|
||||
|
||||
- 查询客户、联系人和跟进记录;
|
||||
- 按负责人、团队、状态或时间范围筛选 CRM 数据;
|
||||
- 查看 CRM 总览、团队贡献和长期未跟进客户;
|
||||
- 查看项目详情和项目跟进时间线。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
列出我负责的、最近 30 天没有有效跟进的客户。
|
||||
```
|
||||
|
||||
```text
|
||||
查看“华东团队”本月的客户、跟进和在途项目概况。
|
||||
```
|
||||
|
||||
## 可选写入
|
||||
|
||||
只有在你明确需要创建或更新项目,并且管理员已经确认你的 NocoBase 权限时,才应启用写入模式。
|
||||
|
||||
写入模式只增加固定字段的项目创建和更新能力,不提供删除操作,也不开放任意集合或任意字段写入。不要为了绕过权限错误而换用共享高权限 Key。
|
||||
|
||||
## 遇到问题
|
||||
|
||||
- 尚未登录:让 Agent 调用 `nocobase_login`,把返回的 `login_url` 和 `user_code` 发给你;授权后调用 `nocobase_login_status`。
|
||||
- 只想检查更新:仍发送上面的统一提示;如果本机已经是 `main` 当前最新 Commit,Agent 会直接验证并报告“已是最新”,不重复构建或登录。
|
||||
- 登录失效:让 Agent 调用 `nocobase_login` 并设置 `force=true`,重新在浏览器授权;正常情况下访问令牌会在后台自动刷新。
|
||||
- 浏览器没有打开:让 Agent 把 `nocobase_login` 返回的 `justconn.top` 链接和确认码发到当前私聊,然后在你的手机或电脑浏览器打开;不需要服务器远程桌面。若当前是群聊,请先转到与 Agent 的一对一私聊。
|
||||
- MCP 客户端暂时无法调用登录工具:可在本人终端运行安装程序的 `setup --no-browser`,这是备用入口。
|
||||
- 仅在 OAuth 暂时不可用且管理员明确要求时,才使用 `setup --manual-key` 的隐藏输入备用模式;不要把 Key 发到聊天中。
|
||||
- 返回 `NO_PERMISSIONS`:请检查本人 NocoBase 角色和数据权限。
|
||||
- 请求超时:确认可以访问 `https://justconn.top`,或缩小查询范围。
|
||||
- 最新 Commit 无法解析或构建失败:停止本次任务并报告 Commit SHA 和失败步骤,不要退回旧 Release。
|
||||
- 源码快照无法下载:确认当前账号或 Agent 已获得 Gitea 仓库的读取权限。
|
||||
|
||||
更多故障处理和更新、回滚说明见 [本机交付与权限验收](docs/LOCAL_DELIVERY.md)。
|
||||
|
||||
## 更多文档
|
||||
|
||||
- [完整安装/更新协议](INSTALL.md):供 Agent 识别本机状态、固定最新 Commit、构建、安装或更新并验收。
|
||||
- [命令与工具参考](docs/REFERENCE.md):CLI 命令、MCP 工具和可选运行开关。
|
||||
- [内部架构](docs/ARCHITECTURE.md):模块边界、身份与网络安全设计。
|
||||
- [本机交付与权限验收](docs/LOCAL_DELIVERY.md):运维、权限验收、更新和回滚。
|
||||
|
||||
## 本地开发
|
||||
|
||||
开发者可从源码运行以下检查:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run check
|
||||
@@ -37,40 +103,4 @@ npm run build
|
||||
node dist/index.js --help
|
||||
```
|
||||
|
||||
开发时初始化个人配置:
|
||||
|
||||
```bash
|
||||
node dist/index.js setup --api-base-url https://nocobase.example --json
|
||||
```
|
||||
|
||||
Authorize Key 只能输入到隐藏提示。不要把 Key 放进参数、环境变量、聊天、`config.json`、MCP 客户端配置或日志。
|
||||
|
||||
可用命令:
|
||||
|
||||
```text
|
||||
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
|
||||
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
|
||||
nocobase-crm-mcp doctor [--config <path>] [--json]
|
||||
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||
```
|
||||
|
||||
## 工具
|
||||
|
||||
默认提供 17 个只读工具:
|
||||
|
||||
- `crm_health`, `crm_schema`, `crm_overview`, `crm_filter_options`
|
||||
- `resolve_owner`, `list_teams`, `resolve_team`, `get_team`, `team_crm_overview`
|
||||
- `list_customers`, `get_customer`, `list_contacts`, `list_followups`
|
||||
- `list_projects`, `get_project`, `get_project_timeline`, `list_stale_customers`
|
||||
|
||||
`NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1` 可临时增加 `crm_config_template`。`include_raw=true` 默认被拒绝;管理员审核后才可设置 `NOCOBASE_MCP_ENABLE_RAW_DATA=1`。
|
||||
|
||||
## 权限边界
|
||||
|
||||
```text
|
||||
AI 客户端 → 本机 TypeScript MCP → HTTPS NocoBase API → 当前用户 Key 对应的 ACL
|
||||
```
|
||||
|
||||
MCP 不缓存共享身份。每个请求都会从当前操作系统用户的私有凭证文件读取 Key,因此撤销或替换 Key 后无需重新生成 MCP 客户端配置。远程地址必须使用 HTTPS(回环地址除外),跨域重定向会在转发 Authorization 前被拒绝,错误内容会脱敏。
|
||||
|
||||
内部结构见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md),运维与权限验收见 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。
|
||||
本项目要求 Node.js 20.11 或更高版本。需要安装或更新时,Agent 会在临时目录从锁定依赖构建最新 Commit;最终运行包不含额外 npm 运行时依赖,用户不需要长期保留源码开发环境。
|
||||
|
||||
+13
-6
@@ -9,24 +9,31 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
|
||||
## 模块边界
|
||||
|
||||
- `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。
|
||||
- `src/credentials.ts` 负责 URL 安全校验、隐藏 TTY 输入和用户级私有凭证文件。Key 不进入 argv、环境变量、CRM 配置或 MCP 配置。
|
||||
- `src/nocobase-endpoints.ts` 集中固定 API、OAuth issuer、发现地址和 resource identifier。
|
||||
- `src/oauth.ts` 实现 OAuth 发现、动态公共客户端注册、设备授权、浏览器启动、轮询和刷新;不读取浏览器 Cookie,也不监听本机端口。
|
||||
- `src/auth-service.ts` 把设备授权包装成 MCP 可调用的开始/状态会话;先返回安全的网页链接,再跨 stdio 进程恢复、验证和保存授权结果。
|
||||
- `src/authorization-store.ts` 管理短期待授权文件、跨进程独占锁、崩溃恢复和锁所有权;`src/credentials.ts` 管理用户级私有凭证及进程内缓存;两者共用 `src/private-json.ts` 的私有目录检查和原子 JSON 写入。默认保存 OAuth 访问/刷新令牌,兼容旧版个人 Key;任何凭据都不进入 argv、环境变量、CRM 配置或 MCP 配置。
|
||||
- `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。
|
||||
- `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。
|
||||
- `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。
|
||||
- `scripts/build.mjs` 用 esbuild 将 MCP SDK、Zod 和所有运行代码打成一个 ESM 文件。Release tarball 没有运行时 npm 依赖。
|
||||
- `scripts/build.mjs` 用 esbuild 将 MCP SDK、Zod 和所有运行代码打成一个 ESM 文件。按 Commit 构建的安装 tarball 和正式 Release tarball 都没有运行时 npm 依赖。
|
||||
|
||||
## 身份与网络安全
|
||||
|
||||
- 每个请求重新读取当前 OS 用户的凭证文件,NocoBase ACL 是最终权限边界。
|
||||
- 非回环 HTTP URL 被拒绝;远程 Key 只能发往 HTTPS。
|
||||
- 凭据在进程内缓存,只有访问令牌临近过期时才进行一次并发合并的刷新;NocoBase ACL 是最终权限边界。
|
||||
- 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。
|
||||
- OAuth issuer、协议端点和浏览器授权 URL 必须属于固定的 `https://justconn.top` 来源。
|
||||
- Device Authorization 请求 `api offline_access`;密码和浏览器会话不会离开 NocoBase。
|
||||
- MCP 可以在无配置、无凭据状态下准备默认配置并启动;只有业务请求需要身份。`nocobase_login` 返回应交给发起用户当前私聊的 OAuth Device Flow 网页链接、确认码和机器可读交付边界;链接不是 Bearer 凭据,无界面服务器不需要远程桌面。内部设备码只进入短期 `0600` 文件;`nocobase_login_status` 可在新进程中继续兑换,完成的 Token 不经过 Agent。
|
||||
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
|
||||
- Token 会从 HTTP 错误中替换为 `<redacted>`。
|
||||
- stdio 是唯一默认传输,不创建后台服务或监听端口。
|
||||
|
||||
## 兼容规则
|
||||
|
||||
- 保持 17 个只读工具名称、参数和主要响应形状稳定。
|
||||
- 保持 17 个 CRM 只读工具名称、参数和主要响应形状稳定,并固定提供 `nocobase_login`、`nocobase_login_status` 两个身份工具。
|
||||
- 写模式只增加 `create_project`、`update_project`,不提供 delete/destroy。
|
||||
- `include_raw` 默认在 MCP 边界拒绝。
|
||||
- 配置继续接受 camelCase 与 snake_case 字段别名。
|
||||
- Release Tag、`package.json` 和 `install-manifest.json` 的版本必须一致。
|
||||
- 默认安装先解析 Gitea `main` 的最新完整 Commit SHA,再在同一快照中读取协议、检查、测试、构建和打包;失败时不回退旧 Release。
|
||||
- 正式 Release 仍要求 Tag、`package.json` 和 `install-manifest.json` 的版本一致,但 Release 不再是默认安装前提。
|
||||
|
||||
+25
-16
@@ -2,22 +2,27 @@
|
||||
|
||||
## 交付模型
|
||||
|
||||
每位用户在自己的电脑运行 MCP,并使用绑定本人业务身份的个人 Authorize Key:
|
||||
每位用户在自己的电脑运行 MCP,并通过 NocoBase 原生 OAuth 登录本人业务身份:
|
||||
|
||||
```text
|
||||
本机 AI 客户端 → 本机 TypeScript MCP → 远程 NocoBase API → 本人 ACL
|
||||
本机 AI 客户端 → 本机 TypeScript MCP → https://justconn.top/api → 本人 ACL
|
||||
```
|
||||
|
||||
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。
|
||||
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。个人 Authorize Key 仅作为显式备用认证方式。
|
||||
|
||||
## Release 发布物
|
||||
## 最新 Commit 安装与更新
|
||||
|
||||
Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查、测试、bundle、npm pack 和生产依赖审计,并上传:
|
||||
默认安装和更新都不等待 Release。用户可以每次发送同一个 INSTALL 链接;Agent 先检查现有 MCP 条目、安装入口和 `install-receipt.json`,再读取 Gitea `main` 的最新完整 Commit SHA,并把这次任务固定在该 SHA。需要首次安装、更新或修复时:
|
||||
|
||||
- npm tarball;
|
||||
- `SHA256SUMS`。
|
||||
- 从带 SHA 的不可变 URL 重新读取 `INSTALL.md`;
|
||||
- 下载同一 SHA 的源码归档;
|
||||
- 在临时目录执行锁定依赖安装、检查、测试、bundle 和 npm pack;
|
||||
- 首次安装到用户级隔离前缀,更新则沿用原前缀并保留配置、凭据和读写模式;
|
||||
- 报告完整 Commit SHA 和本地 tarball SHA-256。
|
||||
|
||||
源码仓库保持 Private 时,外部用户必须具有 Release 下载权限,或通过另一个经过身份认证的分发渠道取得相同资产。Private GitHub 链接本身不能成为任意用户可访问的一键安装链接。
|
||||
若有效安装记录已经是同一最新 SHA,并且二进制、帮助和 MCP 入口均完整,Agent 直接执行配置核对与 `crm_health`,不重复下载、构建或要求重新登录。解析 SHA 后,即使 `main` 在任务期间更新,也不混入新文件;下一次安装或更新才采用更新后的提交。任何检查或构建失败都停止,不回退旧 Release。正式 Release 可以继续用于归档和人工回滚,但不是默认安装来源。
|
||||
|
||||
Gitea 仓库访问受限时,用户或 Agent 必须具有源码和 Commit 归档读取权限。受限仓库链接本身不能成为任意用户可访问的一键安装链接,也不得通过聊天传递访问令牌。
|
||||
|
||||
## 用户电脑上的内容
|
||||
|
||||
@@ -27,30 +32,34 @@ Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查
|
||||
- 当前 OS 用户的私有凭证文件;
|
||||
- MCP 客户端配置和备份。
|
||||
|
||||
不需要 Git、Python、uv、`nb CLI` 或项目源码 checkout。npm 只在隔离前缀安装一个已经 bundle 的包,运行时没有额外 npm 依赖。
|
||||
不需要 Git、Python、uv、`nb CLI` 或持久源码 checkout。npm 在临时目录安装锁定的构建依赖并生成 bundle,随后只在隔离前缀安装本地 tarball;运行时没有额外 npm 依赖。临时源码和构建依赖不属于持久安装内容。
|
||||
|
||||
## 权限验收
|
||||
|
||||
至少使用两个权限不同的真实测试账号:
|
||||
|
||||
1. 分别签发个人 Key,并在隔离的 OS 用户/凭证路径运行 setup。
|
||||
1. 分别在隔离的 OS 用户/凭证路径注册 MCP,调用 `nocobase_login`,再使用两个账号在 NocoBase 网页授权。
|
||||
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups`、`list_projects`。
|
||||
3. 确认返回范围与各自在 NocoBase UI 中的 ACL 一致。
|
||||
4. 无项目写权限账号在 `--enable-writes` 下调用 `create_project`,确认 NocoBase 拒绝。
|
||||
5. 有权限账号创建和更新测试项目,确认审计身份属于该用户。
|
||||
6. 撤销 Key 后确认 MCP 返回 `nocobase_auth_failed`,且不会降级使用其他凭证。
|
||||
7. 检查错误与日志,确认没有 Key、Authorization header 或业务原始行泄漏。
|
||||
6. 让其中一个账号的 OAuth 会话失效,确认 MCP 自动刷新失败后返回 `nocobase_auth_failed`,且不会降级使用其他凭证。
|
||||
7. 检查错误与日志,确认没有访问令牌、刷新令牌、Authorization header、Key 或业务原始行泄漏。
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
Agent 下载新的固定 Release tarball并验证 SHA-256,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev <tarball>`。安装前备份 MCP 客户端配置和非秘密 CRM 配置。
|
||||
更新时,Agent 重新解析 `main` 的最新 Commit;SHA 变化或现有入口不完整时,在新临时目录完成全部检查和构建,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev <locally-built-tarball>`。更新前备份 MCP 客户端配置和非秘密 CRM 配置,并记录更新前后的完整 Commit SHA;沿用现有 `--enable-writes` 状态,除非用户明确要求改变。
|
||||
|
||||
回滚时重新安装上一固定 Release 的已验证 tarball。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 Key。
|
||||
MCP 重载后先调用 `crm_health`。已有 OAuth 登录仍有效时直接完成更新;只有明确返回认证失败时才启动浏览器授权。成功后原子写入不含秘密的 `install-receipt.json`,供下次准确判断是否已经是同一 Commit。
|
||||
|
||||
回滚是唯一允许选择旧提交的安装操作:使用上次记录的完整 Commit SHA 重建并安装该快照,不通过移动分支名猜测版本。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 OAuth 凭据。
|
||||
|
||||
## 常见故障
|
||||
|
||||
- `nocobase_auth_failed`:重新运行 setup,让用户在隐藏提示输入新签发 Key。
|
||||
- `nocobase_auth_failed`:调用 `nocobase_login({"force": true})`,让用户在 NocoBase 网页重新授权。
|
||||
- 浏览器无法启动:把 `nocobase_login` 返回的链接和确认码交给用户手动打开;CLI 的 `setup --no-browser` 仅作备用。
|
||||
- OAuth 服务暂时不可用:先报告服务状态;只有管理员明确要求时才使用 `setup --manual-key`。
|
||||
- `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。
|
||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
|
||||
- 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。
|
||||
- 跨源重定向错误:修正 NocoBase API URL或代理配置;不要关闭同源保护。
|
||||
- OAuth 来源校验错误:检查 `https://justconn.top/api` 的反向代理与发现元数据;不要放宽固定来源保护。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 命令与工具参考
|
||||
|
||||
本文面向集成者和维护者。普通用户请从仓库根目录的 [README](../README.md) 开始;安装 Agent 应从 `main` 的 [INSTALL.md](../INSTALL.md) 进入,先解析最新完整 Commit SHA,再固定该快照完成安装。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
```text
|
||||
nocobase-crm-mcp setup [--config <path>] [--no-browser] [--manual-key] [--json]
|
||||
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
|
||||
nocobase-crm-mcp doctor [--config <path>] [--json]
|
||||
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||
```
|
||||
|
||||
- `setup`:通过 NocoBase OAuth 浏览器授权登录、准备 CRM 配置并输出 MCP 客户端配置。
|
||||
- `serve`:通过 stdio 启动 MCP;省略子命令时也是此模式。尚未登录时也能启动和列出工具。
|
||||
- `doctor`:验证凭据和 CRM 映射是否可用。
|
||||
- `client-config`:准备默认 CRM 配置并输出可合并到 MCP 客户端的配置片段;不要求登录,也不包含任何 Token 或 Key。
|
||||
- `--enable-writes`:显式增加受控项目写入工具;默认不启用。
|
||||
- `--no-browser`:不自动启动浏览器,只输出可交给发起用户的 NocoBase 授权链接和确认码;搭配 `--json` 时还会在 stderr 输出 `authorization_required` 结构化事件。
|
||||
- `--manual-key`:仅在 OAuth 不可用时,通过安全 TTY 隐藏输入个人 Authorize Key。
|
||||
|
||||
## 默认工具
|
||||
|
||||
默认提供 2 个登录工具和 17 个 CRM 只读工具。
|
||||
|
||||
- 登录:`nocobase_login`、`nocobase_login_status`
|
||||
|
||||
- 健康、配置和总览:`crm_health`、`crm_schema`、`crm_overview`、`crm_filter_options`
|
||||
- 用户与团队:`resolve_owner`、`list_teams`、`resolve_team`、`get_team`、`team_crm_overview`
|
||||
- 客户与联系人:`list_customers`、`get_customer`、`list_contacts`
|
||||
- 跟进与项目:`list_followups`、`list_projects`、`get_project`、`get_project_timeline`
|
||||
- 客户维护:`list_stale_customers`
|
||||
|
||||
查询结果默认返回经过整理的紧凑字段,而不是完整 NocoBase 原始记录。
|
||||
|
||||
`nocobase_login` 会启动用户确认的 OAuth 授权,并在成功后写入本机私有凭据文件,因此不会被标记为只读;它不会修改任何 CRM 业务数据。该工具返回 `login_url`、标准别名 `verification_uri_complete`、`user_code`、有效期、状态和机器可读交付边界。链接与确认码应原样发送给发起用户的当前一对一私聊,不返回 Token 或内部设备码,也不需要服务器远程桌面;不得发送到群聊、公开频道或第三方。
|
||||
|
||||
## 可选工具
|
||||
|
||||
设置 `NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1` 时,会临时增加 `crm_config_template`,用于生成带字段元数据的配置参考。
|
||||
|
||||
使用 `--enable-writes` 启动时,只增加:
|
||||
|
||||
- `create_project`
|
||||
- `update_project`
|
||||
|
||||
写入仅允许配置中定义的项目字段和附件关系,不提供删除、任意集合访问或任意字段写入。
|
||||
|
||||
## 原始数据策略
|
||||
|
||||
工具参数中的 `include_raw=true` 默认会被拒绝。只有经过管理员审核并显式设置以下变量后才允许返回原始行:
|
||||
|
||||
```text
|
||||
NOCOBASE_MCP_ENABLE_RAW_DATA=1
|
||||
```
|
||||
|
||||
不应把该变量作为普通用户的默认配置。
|
||||
|
||||
## 运行环境变量
|
||||
|
||||
- `NOCOBASE_MCP_CONFIG`:覆盖 CRM 映射配置文件路径。
|
||||
- `NOCOBASE_MCP_CREDENTIALS`:覆盖当前操作系统用户的凭据文件路径。
|
||||
- `NOCOBASE_MCP_DATA_SOURCE`:覆盖 NocoBase data source 名称。
|
||||
- `NOCOBASE_MCP_TIMEZONE`:覆盖结果显示时区。
|
||||
- `NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1`:启用配置模板工具。
|
||||
- `NOCOBASE_MCP_ENABLE_RAW_DATA=1`:经审核后允许请求原始数据。
|
||||
|
||||
OAuth Token 和 Authorize Key 都不支持也不允许通过环境变量传入。API 地址固定为 `https://justconn.top/api`,不能通过配置或环境变量覆盖。
|
||||
|
||||
## OAuth 登录
|
||||
|
||||
推荐在 MCP 注册后调用 `nocobase_login`,把返回的链接和确认码发给发起用户的当前私聊,再用 `nocobase_login_status` 查询结果。它们属于 OAuth Device Flow 的短期用户验证信息,不是密码、Key、Bearer Token 或内部 `device_code`。待授权状态以短期私有文件保存,因此桌面、远程或无界面环境都不需要回调端口或服务器远程桌面,stdio 进程重启后也能恢复同一个授权。已有凭据被 NocoBase 拒绝时,使用 `nocobase_login({"force": true})` 重新授权。CLI 的 `setup --no-browser` 保留为客户端无法在登录前加载连接器时的持续等待备用入口;运行期间不得关闭该 CLI 进程。
|
||||
|
||||
- 发现地址:`https://justconn.top/api/.well-known/openid-configuration`
|
||||
- 授权模式:OAuth 2.1 Device Authorization
|
||||
- 客户端类型:动态注册的 native public client,不保存 client secret
|
||||
- 请求权限:`api offline_access`
|
||||
- 浏览器只访问 `https://justconn.top`;程序不读取 Cookie 或浏览器存储
|
||||
- 待授权设备码只保存于同一用户的短期私有文件,成功、取消或过期后立即删除
|
||||
- 访问令牌临近过期时自动刷新;并发请求只触发一次刷新
|
||||
- OAuth 端点、浏览器 URL 和 issuer 都必须匹配固定的 `justconn.top` 来源
|
||||
|
||||
## 默认运行边界
|
||||
|
||||
```text
|
||||
AI 客户端 → 本机 stdio MCP → HTTPS NocoBase API → 当前用户 ACL
|
||||
```
|
||||
|
||||
- 启动后凭据缓存在进程内存中;MCP 登录成功时会主动刷新这份缓存,不需要重启连接器。
|
||||
- 只有访问令牌临近过期时才刷新,并把轮换后的凭据写回私有文件。
|
||||
- 默认不创建后台服务,不监听本机或公网端口。
|
||||
- NocoBase ACL、字段权限和行级权限是最终权限边界。
|
||||
- 认证失败、超时和上游请求错误会以结构化错误返回。
|
||||
|
||||
实现细节见 [内部架构](ARCHITECTURE.md),运维和真实权限验收见 [本机交付与权限验收](LOCAL_DELIVERY.md)。
|
||||
+57
-13
@@ -1,50 +1,93 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"schemaVersion": 3,
|
||||
"id": "com.nocobase.crm-mcp",
|
||||
"name": "NocoBase CRM MCP",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"distribution": {
|
||||
"repository": "https://github.com/Donaldttt/nocobase-mcp",
|
||||
"releaseRef": "must-match-v<version>",
|
||||
"guide": "INSTALL.md from the exact Release tag",
|
||||
"assets": ["donaldttt-nocobase-crm-mcp-<version>.tgz", "SHA256SUMS"],
|
||||
"accessRequirement": "Private Release download requires an authenticated user or Agent with explicit repository access."
|
||||
"repository": "https://git.justconn.top/gitea-admin/nocobase-mcp",
|
||||
"branch": "main",
|
||||
"strategy": "resolve-latest-commit-then-pin",
|
||||
"releaseRequired": false,
|
||||
"guide": "INSTALL.md from the resolved full commit SHA",
|
||||
"sourceArchive": "Gitea source archive for the resolved full commit SHA",
|
||||
"build": ["npm ci", "npm run check", "npm test", "npm run build", "npm pack"],
|
||||
"accessRequirement": "Source download requires an authenticated user or Agent with explicit Gitea repository access."
|
||||
},
|
||||
"runtime": {
|
||||
"location": "user-device",
|
||||
"defaultTransport": "stdio",
|
||||
"node": ">=20.11",
|
||||
"packageFormat": "bundled-npm-tarball",
|
||||
"packageFormat": "commit-pinned locally bundled npm tarball",
|
||||
"runtimeNpmDependencies": 0,
|
||||
"nocobaseCli": false,
|
||||
"python": false
|
||||
},
|
||||
"nocobase": {
|
||||
"apiBaseUrl": "https://justconn.top/api",
|
||||
"userConfigurableEndpoint": false,
|
||||
"oauthIssuer": "https://justconn.top/api",
|
||||
"oauthResource": "https://justconn.top/api/"
|
||||
},
|
||||
"installation": {
|
||||
"requiredTools": ["node", "npm"],
|
||||
"operationDetection": ["fresh-install", "in-place-update", "repair", "already-current"],
|
||||
"supportsInPlaceUpdate": true,
|
||||
"sameCommitBuildSkip": true,
|
||||
"installReceipt": {
|
||||
"path": "<install-prefix>/install-receipt.json",
|
||||
"schemaVersion": 1,
|
||||
"posixFileMode": "0600",
|
||||
"containsSecrets": false
|
||||
},
|
||||
"sourceCheckout": false,
|
||||
"temporarySourceArchiveBuild": true,
|
||||
"fallbackToRelease": false,
|
||||
"globalPackageInstall": false,
|
||||
"persistentArtifacts": [
|
||||
"isolated user-data npm prefix",
|
||||
"non-secret install receipt",
|
||||
"non-secret CRM config",
|
||||
"private per-user credential file",
|
||||
"MCP client configuration and backup"
|
||||
],
|
||||
"preserveOnUpdate": ["CRM config", "per-user OAuth credentials", "read-only or fixed-project-write mode"],
|
||||
"defaultBackgroundService": false
|
||||
},
|
||||
"authentication": {
|
||||
"type": "personal-authorize-key",
|
||||
"secretEntry": "user-controlled-hidden-tty",
|
||||
"type": "oauth-2.1-device-authorization",
|
||||
"discovery": "https://justconn.top/api/.well-known/openid-configuration",
|
||||
"scopes": ["api", "offline_access"],
|
||||
"dynamicClientRegistration": true,
|
||||
"browserSignIn": true,
|
||||
"automaticRefresh": true,
|
||||
"processRestartResumable": true,
|
||||
"pendingAuthorizationStorage": "short-lived per-user private local file",
|
||||
"userVerificationDelivery": {
|
||||
"fields": ["login_url", "verification_uri_complete", "user_code"],
|
||||
"intendedRecipient": "requesting-user",
|
||||
"channel": "current-private-conversation",
|
||||
"shareableWithIntendedRecipient": true,
|
||||
"publicSharingAllowed": false,
|
||||
"containsBearerCredentials": false,
|
||||
"containsDeviceCode": false,
|
||||
"remoteDesktopRequired": false
|
||||
},
|
||||
"manualFallback": "user-controlled-hidden-tty-personal-authorize-key",
|
||||
"storage": "per-user private local credential file",
|
||||
"posixFileMode": "0600",
|
||||
"allowPassword": false,
|
||||
"allowAgentChatSecret": false,
|
||||
"allowCommandLineSecret": false,
|
||||
"allowEnvironmentSecret": false
|
||||
"allowEnvironmentSecret": false,
|
||||
"allowDeviceCodeExposure": false
|
||||
},
|
||||
"entrypoints": {
|
||||
"guide": "INSTALL.md",
|
||||
"binary": "nocobase-crm-mcp",
|
||||
"guidedCommand": "setup",
|
||||
"guidedCommand": "client-config",
|
||||
"mcpLoginTool": "nocobase_login",
|
||||
"mcpLoginStatusTool": "nocobase_login_status",
|
||||
"cliLoginFallback": "setup --no-browser",
|
||||
"configTemplate": "config.example.json"
|
||||
},
|
||||
"securityDefaults": {
|
||||
@@ -54,6 +97,7 @@
|
||||
"sharedNocobaseCredential": false,
|
||||
"networkBind": "none-stdio",
|
||||
"remoteHttpRequiresTls": true,
|
||||
"crossOriginAuthRedirect": false
|
||||
"crossOriginAuthRedirect": false,
|
||||
"browserCredentialRead": false
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@donaldttt/nocobase-crm-mcp",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@donaldttt/nocobase-crm-mcp",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "UNLICENSED",
|
||||
"bin": {
|
||||
"nocobase-crm-mcp": "dist/index.js"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@donaldttt/nocobase-crm-mcp",
|
||||
"version": "0.3.0",
|
||||
"description": "Local NocoBase CRM MCP server using each user's personal Authorize Key",
|
||||
"version": "0.4.0",
|
||||
"description": "Local NocoBase CRM MCP server with per-user NocoBase OAuth browser sign-in",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { AuthorizationStore, pendingAuthorizationPath } from "./authorization-store.js";
|
||||
import {
|
||||
credentialsPath,
|
||||
loadStoredCredentials,
|
||||
NOCOBASE_API_BASE_URL,
|
||||
type StoredCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "./credentials.js";
|
||||
import { NocoBaseClient } from "./nocobase-client.js";
|
||||
import {
|
||||
type BrowserLoginOptions,
|
||||
type BrowserLoginPollResult,
|
||||
beginBrowserLogin,
|
||||
type OAuthCredentials,
|
||||
OAuthProtocolError,
|
||||
type PendingOAuthAuthorization,
|
||||
pollBrowserLogin,
|
||||
} from "./oauth.js";
|
||||
|
||||
export type AuthPayload = Record<string, unknown>;
|
||||
|
||||
export function userAuthorizationFields(loginUrl: string, userCode: string): AuthPayload {
|
||||
return {
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: loginUrl,
|
||||
verification_uri_complete: loginUrl,
|
||||
user_code: userCode,
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
start(options?: { force?: boolean }): Promise<AuthPayload>;
|
||||
status(): Promise<AuthPayload>;
|
||||
}
|
||||
|
||||
export interface NocoBaseAuthServiceOptions {
|
||||
credentialsFile?: string;
|
||||
pendingAuthorizationFile?: string;
|
||||
now?: () => number;
|
||||
openBrowser?: BrowserLoginOptions["openBrowser"];
|
||||
beginLogin?: (options: BrowserLoginOptions) => Promise<PendingOAuthAuthorization>;
|
||||
pollLogin?: (authorization: PendingOAuthAuthorization) => Promise<BrowserLoginPollResult>;
|
||||
verifyCredentials?: (credentials: OAuthCredentials) => Promise<void>;
|
||||
onCredentialsSaved?: () => void;
|
||||
}
|
||||
|
||||
export class NocoBaseAuthService implements AuthService {
|
||||
private readonly credentialsFile: string;
|
||||
private readonly authorizationStore: AuthorizationStore;
|
||||
private readonly now: () => number;
|
||||
private readonly openBrowser?: BrowserLoginOptions["openBrowser"];
|
||||
private readonly beginLogin: (options: BrowserLoginOptions) => Promise<PendingOAuthAuthorization>;
|
||||
private readonly pollLogin: (authorization: PendingOAuthAuthorization) => Promise<BrowserLoginPollResult>;
|
||||
private readonly verifyCredentials: (credentials: OAuthCredentials) => Promise<void>;
|
||||
private readonly onCredentialsSaved?: () => void;
|
||||
private lastFailure?: string;
|
||||
private startInFlight?: Promise<AuthPayload>;
|
||||
private pollInFlight?: Promise<AuthPayload>;
|
||||
|
||||
constructor(options: NocoBaseAuthServiceOptions = {}) {
|
||||
this.credentialsFile = options.credentialsFile ?? credentialsPath();
|
||||
this.authorizationStore = new AuthorizationStore(
|
||||
options.pendingAuthorizationFile ?? pendingAuthorizationPath(this.credentialsFile),
|
||||
);
|
||||
this.now = options.now ?? Date.now;
|
||||
this.openBrowser = options.openBrowser;
|
||||
this.beginLogin = options.beginLogin ?? beginBrowserLogin;
|
||||
this.pollLogin =
|
||||
options.pollLogin ?? ((authorization) => pollBrowserLogin(authorization, { now: this.now }));
|
||||
this.verifyCredentials = options.verifyCredentials ?? verifyOAuthCredentials;
|
||||
this.onCredentialsSaved = options.onCredentialsSaved;
|
||||
}
|
||||
|
||||
async start(options: { force?: boolean } = {}): Promise<AuthPayload> {
|
||||
return await this.singleFlight("startInFlight", () => this.startInternal(options.force ?? false));
|
||||
}
|
||||
|
||||
async status(): Promise<AuthPayload> {
|
||||
let pending: PendingOAuthAuthorization | undefined;
|
||||
try {
|
||||
pending = this.authorizationStore.read();
|
||||
} catch (error) {
|
||||
return this.authorizationFailure(errorMessage(error));
|
||||
}
|
||||
const stored = this.tryLoadStoredCredentials();
|
||||
if (pending) {
|
||||
if (this.credentialsCompleted(pending, stored)) {
|
||||
this.authorizationStore.remove();
|
||||
return this.credentialsSaved();
|
||||
}
|
||||
if (pending.expiresAt <= this.now()) {
|
||||
this.authorizationStore.remove();
|
||||
return this.authorizationFailure("NocoBase browser authorization expired; start a new login");
|
||||
}
|
||||
return await this.pollPendingAuthorization(pending);
|
||||
}
|
||||
if (stored) {
|
||||
this.lastFailure = undefined;
|
||||
return authenticatedPayload();
|
||||
}
|
||||
if (this.lastFailure) return failedPayload(this.lastFailure);
|
||||
return loginRequiredPayload();
|
||||
}
|
||||
|
||||
private async startInternal(force: boolean): Promise<AuthPayload> {
|
||||
try {
|
||||
const pending = this.authorizationStore.read();
|
||||
if (pending && pending.expiresAt > this.now()) return this.pendingPayload(pending);
|
||||
if (pending) this.authorizationStore.remove();
|
||||
} catch (error) {
|
||||
return this.authorizationFailure(errorMessage(error));
|
||||
}
|
||||
const existing = this.tryLoadStoredCredentials();
|
||||
if (!force && existing) {
|
||||
this.lastFailure = undefined;
|
||||
return authenticatedPayload();
|
||||
}
|
||||
const lock = this.authorizationStore.tryLock(this.now());
|
||||
if (!lock) return await this.waitForConcurrentAuthorizationStart();
|
||||
try {
|
||||
const concurrent = this.authorizationStore.read();
|
||||
if (concurrent && concurrent.expiresAt > this.now()) return this.pendingPayload(concurrent);
|
||||
if (concurrent) this.authorizationStore.remove();
|
||||
const stored = this.tryLoadStoredCredentials();
|
||||
if (!force && stored) {
|
||||
this.lastFailure = undefined;
|
||||
return authenticatedPayload();
|
||||
}
|
||||
this.lastFailure = undefined;
|
||||
const authorization = await this.beginLogin({
|
||||
clientId: stored?.type === "oauth" ? stored.clientId : undefined,
|
||||
openBrowser: this.openBrowser,
|
||||
now: this.now,
|
||||
});
|
||||
this.authorizationStore.save(authorization);
|
||||
return this.pendingPayload(authorization);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForConcurrentAuthorizationStart(): Promise<AuthPayload> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await delay(100);
|
||||
try {
|
||||
const pending = this.authorizationStore.read();
|
||||
if (pending && pending.expiresAt > this.now()) return this.pendingPayload(pending);
|
||||
} catch (error) {
|
||||
return this.authorizationFailure(errorMessage(error));
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "authorization_starting",
|
||||
retry_after_seconds: 1,
|
||||
next_action:
|
||||
"Another MCP process is creating the browser authorization. Call nocobase_login again shortly; do not start a separate login flow.",
|
||||
};
|
||||
}
|
||||
|
||||
private async pollPendingAuthorization(authorization: PendingOAuthAuthorization): Promise<AuthPayload> {
|
||||
return await this.singleFlight("pollInFlight", () =>
|
||||
this.pollPendingAuthorizationInternal(authorization),
|
||||
);
|
||||
}
|
||||
|
||||
private async singleFlight(
|
||||
slot: "startInFlight" | "pollInFlight",
|
||||
operation: () => Promise<AuthPayload>,
|
||||
): Promise<AuthPayload> {
|
||||
const current = this[slot];
|
||||
if (current) return await current;
|
||||
const pending = operation();
|
||||
this[slot] = pending;
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
if (this[slot] === pending) this[slot] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollPendingAuthorizationInternal(
|
||||
authorization: PendingOAuthAuthorization,
|
||||
): Promise<AuthPayload> {
|
||||
if (authorization.nextPollAt > this.now()) return this.pendingPayload(authorization);
|
||||
const lock = this.authorizationStore.tryLock(this.now());
|
||||
if (!lock) {
|
||||
return {
|
||||
...this.pendingPayload(authorization),
|
||||
retry_after_seconds: 1,
|
||||
poll_in_progress: true,
|
||||
next_action:
|
||||
"Another MCP process is checking this same authorization. Wait 1 second, then call nocobase_login_status again; do not ask the user to log in again.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const latest = this.authorizationStore.read();
|
||||
if (!latest) {
|
||||
if (this.tryLoadStoredCredentials()) {
|
||||
this.lastFailure = undefined;
|
||||
return authenticatedPayload();
|
||||
}
|
||||
return loginRequiredPayload();
|
||||
}
|
||||
if (this.credentialsCompleted(latest, this.tryLoadStoredCredentials())) {
|
||||
this.authorizationStore.remove();
|
||||
return this.credentialsSaved();
|
||||
}
|
||||
if (latest.expiresAt <= this.now()) {
|
||||
this.authorizationStore.remove();
|
||||
return this.authorizationFailure("NocoBase browser authorization expired; start a new login");
|
||||
}
|
||||
let result: BrowserLoginPollResult;
|
||||
try {
|
||||
result = await this.pollLogin(latest);
|
||||
} catch (error) {
|
||||
if (isTerminalAuthorizationError(error)) {
|
||||
this.authorizationStore.remove();
|
||||
return this.authorizationFailure(errorMessage(error));
|
||||
}
|
||||
return {
|
||||
...this.pendingPayload(latest),
|
||||
poll_error: {
|
||||
code: "nocobase_authorization_poll_failed",
|
||||
message: errorMessage(error),
|
||||
retryable: true,
|
||||
},
|
||||
next_action: "Retry nocobase_login_status; do not create a new login link yet.",
|
||||
};
|
||||
}
|
||||
if (result.status === "authorization_pending") {
|
||||
this.authorizationStore.save(result.authorization);
|
||||
return this.pendingPayload(result.authorization);
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
...result.credentials,
|
||||
authorizationSessionId: latest.sessionId,
|
||||
};
|
||||
saveOAuthCredentials(credentials, this.credentialsFile);
|
||||
this.authorizationStore.remove();
|
||||
const authenticated = this.credentialsSaved();
|
||||
try {
|
||||
await this.verifyCredentials(credentials);
|
||||
return authenticated;
|
||||
} catch (error) {
|
||||
return {
|
||||
...authenticated,
|
||||
credential_verification: "failed",
|
||||
verification_error: {
|
||||
code: "nocobase_credential_verification_failed",
|
||||
message: errorMessage(error),
|
||||
retryable: true,
|
||||
},
|
||||
next_action:
|
||||
"Credentials were saved. Call crm_health; create a new login only if it reports an authentication failure.",
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private pendingPayload(authorization: PendingOAuthAuthorization): AuthPayload {
|
||||
return {
|
||||
status: "authorization_pending",
|
||||
...userAuthorizationFields(authorization.prompt.url, authorization.prompt.userCode),
|
||||
expires_in_seconds: Math.max(0, Math.ceil((authorization.expiresAt - this.now()) / 1_000)),
|
||||
retry_after_seconds: Math.max(0, Math.ceil((authorization.nextPollAt - this.now()) / 1_000)),
|
||||
browser_opened: authorization.prompt.opened,
|
||||
next_action:
|
||||
"Deliver login_url and user_code verbatim to the requesting user once. On a headless or remote server, send them in the current private conversation: they are OAuth device user-verification data, not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party. Then wait retry_after_seconds and call nocobase_login_status. Reuse this authorization; do not ask the user to log in again.",
|
||||
};
|
||||
}
|
||||
|
||||
private tryLoadStoredCredentials(): StoredCredentials | undefined {
|
||||
try {
|
||||
return loadStoredCredentials(this.credentialsFile);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private credentialsCompleted(
|
||||
authorization: PendingOAuthAuthorization,
|
||||
stored: StoredCredentials | undefined,
|
||||
): boolean {
|
||||
return stored?.type === "oauth" && stored.authorizationSessionId === authorization.sessionId;
|
||||
}
|
||||
|
||||
private credentialsSaved(): AuthPayload {
|
||||
this.lastFailure = undefined;
|
||||
this.onCredentialsSaved?.();
|
||||
return authenticatedPayload();
|
||||
}
|
||||
|
||||
private authorizationFailure(message: string): AuthPayload {
|
||||
this.lastFailure = message;
|
||||
return failedPayload(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalAuthorizationError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof OAuthProtocolError &&
|
||||
["access_denied", "expired_token", "invalid_client", "invalid_grant"].includes(error.code ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyOAuthCredentials(credentials: OAuthCredentials): Promise<void> {
|
||||
const client = new NocoBaseClient(() => ({
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey: credentials.accessToken,
|
||||
}));
|
||||
await client.verifyCredentials();
|
||||
}
|
||||
|
||||
function authenticatedPayload(): AuthPayload {
|
||||
return {
|
||||
status: "authenticated",
|
||||
next_action: "Call crm_health, then continue with the requested CRM operation.",
|
||||
};
|
||||
}
|
||||
|
||||
function failedPayload(message: string): AuthPayload {
|
||||
return {
|
||||
status: "authorization_failed",
|
||||
error: {
|
||||
code: "nocobase_authorization_failed",
|
||||
message,
|
||||
retryable: true,
|
||||
},
|
||||
next_action: "Call nocobase_login again to create a new browser authorization link.",
|
||||
};
|
||||
}
|
||||
|
||||
function loginRequiredPayload(): AuthPayload {
|
||||
return {
|
||||
status: "login_required",
|
||||
next_action:
|
||||
"Call nocobase_login. Deliver its login_url and user_code to the requesting user exactly as instructed, including through the current private conversation on a headless server, then poll nocobase_login_status; do not require remote desktop.",
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function delay(milliseconds: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { closeSync, fchmodSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { type PendingOAuthAuthorization, validatePendingOAuthAuthorization } from "./oauth.js";
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
readPrivateJson,
|
||||
removePrivateFile,
|
||||
writePrivateJson,
|
||||
} from "./private-json.js";
|
||||
import { isRecord } from "./types.js";
|
||||
|
||||
export interface AuthorizationLock {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export function pendingAuthorizationPath(credentialsFile: string): string {
|
||||
return `${credentialsFile}.pending-oauth`;
|
||||
}
|
||||
|
||||
export class AuthorizationStore {
|
||||
constructor(readonly path: string) {}
|
||||
|
||||
read(): PendingOAuthAuthorization | undefined {
|
||||
try {
|
||||
return validatePendingOAuthAuthorization(readPrivateJson(this.path, "Pending authorization"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
save(authorization: PendingOAuthAuthorization): void {
|
||||
writePrivateJson(this.path, validatePendingOAuthAuthorization(authorization), "Authorization");
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
removePrivateFile(this.path);
|
||||
}
|
||||
|
||||
tryLock(now: number): AuthorizationLock | undefined {
|
||||
ensurePrivateDirectory(this.path, "Authorization");
|
||||
const lockPath = `${this.path}.lock`;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const token = randomUUID();
|
||||
try {
|
||||
const descriptor = openSync(lockPath, "wx", 0o600);
|
||||
try {
|
||||
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
||||
writeFileSync(descriptor, JSON.stringify({ pid: process.pid, createdAt: now, token }), "utf8");
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
return ownedLock(lockPath, token);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
if (attempt === 0 && authorizationLockIsStale(lockPath, now)) {
|
||||
try {
|
||||
unlinkSync(lockPath);
|
||||
continue;
|
||||
} catch (unlinkError) {
|
||||
if ((unlinkError as NodeJS.ErrnoException).code === "ENOENT") continue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTHORIZATION_LOCK_STALE_MS = 120_000;
|
||||
|
||||
function ownedLock(path: string, token: string): AuthorizationLock {
|
||||
return {
|
||||
release: () => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (isRecord(value) && value.token === token) unlinkSync(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorizationLockIsStale(path: string, now: number): boolean {
|
||||
try {
|
||||
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (isRecord(value) && typeof value.pid === "number" && typeof value.createdAt === "number") {
|
||||
if (now - value.createdAt > AUTHORIZATION_LOCK_STALE_MS) return true;
|
||||
return !processIsAlive(value.pid);
|
||||
}
|
||||
return now - statSync(path).mtimeMs > AUTHORIZATION_LOCK_STALE_MS;
|
||||
} catch {
|
||||
try {
|
||||
return now - statSync(path).mtimeMs > AUTHORIZATION_LOCK_STALE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processIsAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
+124
-35
@@ -2,26 +2,31 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { NocoBaseAuthService, userAuthorizationFields } from "./auth-service.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import {
|
||||
createCredentialProvider,
|
||||
credentialsPath,
|
||||
loadCredentials,
|
||||
normalizeApiBaseUrl,
|
||||
loadStoredCredentials,
|
||||
NOCOBASE_API_BASE_URL,
|
||||
readHidden,
|
||||
saveCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "./credentials.js";
|
||||
import { CrmService } from "./crm-service.js";
|
||||
import { NocoBaseClient } from "./nocobase-client.js";
|
||||
import { type AuthorizationPrompt, loginWithBrowser, type OAuthCredentials } from "./oauth.js";
|
||||
import { ProjectService } from "./project-service.js";
|
||||
import { createServer } from "./tools.js";
|
||||
import { VERSION } from "./version.js";
|
||||
|
||||
interface ParsedArgs {
|
||||
command: "serve" | "setup" | "doctor" | "client-config" | "help" | "version";
|
||||
apiBaseUrl?: string;
|
||||
config?: string;
|
||||
enableWrites: boolean;
|
||||
json: boolean;
|
||||
manualKey: boolean;
|
||||
noBrowser: boolean;
|
||||
}
|
||||
|
||||
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||
@@ -38,7 +43,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||
await setup(args);
|
||||
return;
|
||||
}
|
||||
const configPath = resolveConfigPath(args.config);
|
||||
const configPath = ensureConfig(args.config);
|
||||
if (args.command === "client-config") {
|
||||
printResult(clientConfig(configPath, args.enableWrites), args.json);
|
||||
return;
|
||||
@@ -51,6 +56,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const server = createServer({
|
||||
authService: new NocoBaseAuthService({ onCredentialsSaved: runtime.reloadCredentials }),
|
||||
crmService: runtime.crm,
|
||||
projectService: runtime.projects,
|
||||
readonly: !args.enableWrites,
|
||||
@@ -69,40 +75,106 @@ export function createRuntime(configPath: string): {
|
||||
client: NocoBaseClient;
|
||||
crm: CrmService;
|
||||
projects: ProjectService;
|
||||
reloadCredentials: () => void;
|
||||
} {
|
||||
const config = loadConfig(configPath);
|
||||
const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource });
|
||||
const credentialProvider = createCredentialProvider();
|
||||
const client = new NocoBaseClient(credentialProvider, { dataSource: config.dataSource });
|
||||
return {
|
||||
client,
|
||||
crm: new CrmService(config, client),
|
||||
projects: new ProjectService(config.project, client),
|
||||
reloadCredentials: credentialProvider.reset,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(args: ParsedArgs): Promise<void> {
|
||||
if (!args.apiBaseUrl) throw new Error("setup requires --api-base-url <https://nocobase.example>");
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
|
||||
let authorizeKey: string;
|
||||
let authentication: "oauth-browser" | "personal-authorize-key-fallback";
|
||||
let configPath: string;
|
||||
let oauthCredentials: OAuthCredentials | undefined;
|
||||
if (args.manualKey) {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new Error("Manual Key setup requires a user-controlled local terminal; no files were changed");
|
||||
}
|
||||
process.stderr.write(
|
||||
`This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` +
|
||||
"Enter a revocable personal Authorize Key only in the hidden prompt below.\n" +
|
||||
"Do not paste the Key into Agent chat.\n",
|
||||
);
|
||||
configPath = ensureConfig(args.config);
|
||||
authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
|
||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
|
||||
authentication = "personal-authorize-key-fallback";
|
||||
} else {
|
||||
configPath = ensureConfig(args.config);
|
||||
process.stderr.write(
|
||||
`Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` +
|
||||
"Your password and browser session stay inside NocoBase.\n",
|
||||
);
|
||||
oauthCredentials = await loginWithBrowser({
|
||||
clientId: reusableOAuthClientId(),
|
||||
openBrowser: args.noBrowser ? async () => false : undefined,
|
||||
onAuthorization: (prompt) => {
|
||||
if (args.json) {
|
||||
process.stderr.write(`${JSON.stringify(setupAuthorizationEvent(prompt))}\n`);
|
||||
return;
|
||||
}
|
||||
process.stderr.write(formatSetupAuthorizationPrompt(prompt));
|
||||
},
|
||||
});
|
||||
authorizeKey = oauthCredentials.accessToken;
|
||||
authentication = "oauth-browser";
|
||||
}
|
||||
const apiBaseUrl = normalizeApiBaseUrl(args.apiBaseUrl);
|
||||
process.stderr.write(
|
||||
"Create a revocable personal Authorize Key in NocoBase for your own business account.\n" +
|
||||
"Do not paste the Key into Agent chat. Enter it only in the hidden prompt below.\n",
|
||||
);
|
||||
const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
|
||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
|
||||
const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl, authorizeKey }));
|
||||
const verificationClient = new NocoBaseClient(() => ({
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey,
|
||||
}));
|
||||
await verificationClient.verifyCredentials();
|
||||
const configPath = ensureConfig(args.config);
|
||||
saveCredentials({ apiBaseUrl, authorizeKey });
|
||||
if (oauthCredentials) saveOAuthCredentials(oauthCredentials);
|
||||
else saveCredentials({ authorizeKey });
|
||||
await printSetupResult(args, configPath, authentication);
|
||||
}
|
||||
|
||||
export function setupAuthorizationEvent(prompt: AuthorizationPrompt): Record<string, unknown> {
|
||||
return {
|
||||
event: "authorization_required",
|
||||
status: "authorization_pending",
|
||||
...userAuthorizationFields(prompt.url, prompt.userCode),
|
||||
expires_in_seconds: prompt.expiresIn,
|
||||
browser_opened: prompt.opened,
|
||||
next_action:
|
||||
"Send login_url and user_code verbatim to the requesting user in the current private conversation, then keep this setup process running. They are OAuth device user-verification data, not a password, API key, bearer token, or device_code; remote desktop is not required. Never post them publicly or send them to a third party.",
|
||||
};
|
||||
}
|
||||
|
||||
function formatSetupAuthorizationPrompt(prompt: AuthorizationPrompt): string {
|
||||
return (
|
||||
"OAuth device sign-in information for the requesting user follows.\n" +
|
||||
"For a headless or remote Agent, relay the URL and confirmation code verbatim in the current private conversation; they are not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party.\n" +
|
||||
`${prompt.opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${prompt.url}\n` +
|
||||
`Confirmation code: ${prompt.userCode}\n` +
|
||||
`Complete sign-in within ${Math.ceil(prompt.expiresIn / 60)} minutes. Waiting for approval...\n`
|
||||
);
|
||||
}
|
||||
|
||||
async function printSetupResult(
|
||||
args: ParsedArgs,
|
||||
configPath: string,
|
||||
authentication: "oauth-browser" | "personal-authorize-key-fallback",
|
||||
): Promise<void> {
|
||||
const runtime = createRuntime(configPath);
|
||||
const health = await runtime.crm.crmHealth();
|
||||
printResult(
|
||||
{
|
||||
ok: true,
|
||||
apiBaseUrl,
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authentication,
|
||||
credentialsPath: credentialsPath(),
|
||||
keyExposure: "private-tty-and-0600-local-file-only",
|
||||
credentialExposure:
|
||||
authentication === "oauth-browser"
|
||||
? "nocobase-browser-and-0600-local-file-only"
|
||||
: "private-tty-and-0600-local-file-only",
|
||||
configPath,
|
||||
mode: args.enableWrites ? "fixed-project-writes" : "read-only",
|
||||
clientConfig: clientConfig(configPath, args.enableWrites),
|
||||
@@ -112,9 +184,17 @@ async function setup(args: ParsedArgs): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function reusableOAuthClientId(): string | undefined {
|
||||
const path = credentialsPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
const stored = loadStoredCredentials(path);
|
||||
return stored.type === "oauth" ? stored.clientId : undefined;
|
||||
}
|
||||
|
||||
function ensureConfig(explicit?: string): string {
|
||||
if (explicit) {
|
||||
const path = resolve(explicit);
|
||||
const configured = explicit ?? process.env.NOCOBASE_MCP_CONFIG?.trim();
|
||||
if (configured) {
|
||||
const path = resolve(configured);
|
||||
if (!existsSync(path)) throw new Error(`Config file does not exist: ${path}`);
|
||||
return path;
|
||||
}
|
||||
@@ -128,13 +208,6 @@ function ensureConfig(explicit?: string): string {
|
||||
return destination;
|
||||
}
|
||||
|
||||
function resolveConfigPath(explicit?: string): string {
|
||||
const path = explicit ?? process.env.NOCOBASE_MCP_CONFIG ?? join(dirname(credentialsPath()), "config.json");
|
||||
const resolved = resolve(path);
|
||||
if (!existsSync(resolved)) throw new Error(`CRM config is missing: ${resolved}. Run setup first.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function clientConfig(configPath: string, enableWrites = false): Record<string, unknown> {
|
||||
const entrypoint = resolve(process.argv[1] ?? fileURLToPath(import.meta.url));
|
||||
return {
|
||||
@@ -158,22 +231,34 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
command = first as ParsedArgs["command"];
|
||||
index = 1;
|
||||
}
|
||||
const parsed: ParsedArgs = { command, enableWrites: false, json: false };
|
||||
const parsed: ParsedArgs = {
|
||||
command,
|
||||
enableWrites: false,
|
||||
json: false,
|
||||
manualKey: false,
|
||||
noBrowser: false,
|
||||
};
|
||||
while (index < argv.length) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--help" || flag === "-h") parsed.command = "help";
|
||||
else if (flag === "--version" || flag === "-v") parsed.command = "version";
|
||||
else if (flag === "--enable-writes") parsed.enableWrites = true;
|
||||
else if (flag === "--json") parsed.json = true;
|
||||
else if (flag === "--api-base-url" || flag === "--config") {
|
||||
else if (flag === "--manual-key") parsed.manualKey = true;
|
||||
else if (flag === "--no-browser") parsed.noBrowser = true;
|
||||
else if (flag === "--config") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
|
||||
if (flag === "--api-base-url") parsed.apiBaseUrl = value;
|
||||
else parsed.config = value;
|
||||
parsed.config = value;
|
||||
index += 1;
|
||||
} else throw new Error(`Unknown option: ${flag}`);
|
||||
index += 1;
|
||||
}
|
||||
if (parsed.command !== "setup" && (parsed.manualKey || parsed.noBrowser)) {
|
||||
throw new Error("--manual-key and --no-browser are available only with setup");
|
||||
}
|
||||
if (parsed.manualKey && parsed.noBrowser)
|
||||
throw new Error("--manual-key cannot be combined with --no-browser");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -185,12 +270,16 @@ function helpText(): string {
|
||||
return `NocoBase CRM MCP ${VERSION}
|
||||
|
||||
Usage:
|
||||
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
|
||||
nocobase-crm-mcp setup [--config <path>] [--no-browser] [--manual-key] [--json]
|
||||
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
|
||||
nocobase-crm-mcp doctor [--config <path>] [--json]
|
||||
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||
|
||||
The setup command accepts the personal Authorize Key only through a hidden local TTY prompt.
|
||||
The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}.
|
||||
The setup command signs in through NocoBase OAuth in the user's browser.
|
||||
Use --no-browser to print the authorization URL without opening it.
|
||||
Use --manual-key only as a hidden local TTY fallback.
|
||||
The stdio server can start before login; call nocobase_login to obtain a user-facing browser URL.
|
||||
The default stdio server is read-only.
|
||||
`;
|
||||
}
|
||||
|
||||
+142
-49
@@ -1,6 +1,8 @@
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { NOCOBASE_API_BASE_URL, NOCOBASE_OAUTH_ISSUER } from "./nocobase-endpoints.js";
|
||||
import { type OAuthCredentials, refreshOAuthCredentials } from "./oauth.js";
|
||||
import { readPrivateJson, writePrivateJson } from "./private-json.js";
|
||||
import { isRecord } from "./types.js";
|
||||
|
||||
export interface Credentials {
|
||||
@@ -8,20 +10,26 @@ export interface Credentials {
|
||||
authorizeKey: string;
|
||||
}
|
||||
|
||||
export function normalizeApiBaseUrl(value: string): string {
|
||||
const raw = value.trim();
|
||||
if (!raw) throw new Error("NocoBase URL is required");
|
||||
const url = new URL(raw);
|
||||
if (!(["http:", "https:"] as string[]).includes(url.protocol))
|
||||
throw new Error("NocoBase URL must use HTTP or HTTPS");
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new Error("NocoBase URL must not contain credentials, a query string, or a fragment");
|
||||
}
|
||||
const local = url.hostname === "localhost" || url.hostname === "::1" || url.hostname.startsWith("127.");
|
||||
if (url.protocol !== "https:" && !local)
|
||||
throw new Error("A remote Authorize Key may only be sent over HTTPS");
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${url.pathname.replace(/\/+$/, "").endsWith("/api") ? "" : "/api"}`;
|
||||
return url.toString().replace(/\/$/, "");
|
||||
interface PersonalKeyCredentials {
|
||||
type: "personal-authorize-key";
|
||||
authorizeKey: string;
|
||||
}
|
||||
|
||||
export type StoredCredentials = PersonalKeyCredentials | OAuthCredentials;
|
||||
|
||||
export { NOCOBASE_API_BASE_URL };
|
||||
|
||||
export interface CredentialProviderOptions {
|
||||
now?: () => number;
|
||||
path?: string;
|
||||
refresh?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
|
||||
refreshRetryDelayMs?: number;
|
||||
refreshSkewMs?: number;
|
||||
}
|
||||
|
||||
export interface CredentialProvider {
|
||||
(): Promise<Credentials>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function credentialsPath(): string {
|
||||
@@ -38,43 +46,105 @@ export function credentialsPath(): string {
|
||||
return join(base, "nocobase-crm-mcp", "credentials.json");
|
||||
}
|
||||
|
||||
export function saveCredentials(credentials: Credentials, path = credentialsPath()): void {
|
||||
const normalized: Credentials = {
|
||||
apiBaseUrl: normalizeApiBaseUrl(credentials.apiBaseUrl),
|
||||
authorizeKey: credentials.authorizeKey.trim(),
|
||||
};
|
||||
if (!normalized.authorizeKey) throw new Error("Authorize Key must not be blank");
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const directoryMode = statSync(dirname(path)).mode & 0o777;
|
||||
if ((directoryMode & 0o077) !== 0)
|
||||
throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`);
|
||||
} catch (error) {
|
||||
if (existsSync(dirname(path))) throw error;
|
||||
}
|
||||
}
|
||||
const descriptor = openSync(path, "w", 0o600);
|
||||
try {
|
||||
writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
export function saveCredentials(
|
||||
credentials: Pick<Credentials, "authorizeKey">,
|
||||
path = credentialsPath(),
|
||||
): void {
|
||||
const authorizeKey = credentials.authorizeKey.trim();
|
||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank");
|
||||
writePrivateJson(path, { authorizeKey }, "Credential");
|
||||
}
|
||||
|
||||
export function saveOAuthCredentials(credentials: OAuthCredentials, path = credentialsPath()): void {
|
||||
validateOAuthCredentials(credentials);
|
||||
writePrivateJson(path, credentials, "Credential");
|
||||
}
|
||||
|
||||
export function loadCredentials(path = credentialsPath()): Credentials {
|
||||
if (process.platform !== "win32") {
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0)
|
||||
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
|
||||
const stored = loadStoredCredentials(path);
|
||||
return {
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey: stored.type === "oauth" ? stored.accessToken : stored.authorizeKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoredCredentials(path = credentialsPath()): StoredCredentials {
|
||||
const parsed = readPrivateJson(path, "Credential");
|
||||
if (!isRecord(parsed)) throw new Error("Credential file is invalid; sign in again");
|
||||
if (parsed.type === "oauth") {
|
||||
const oauth = parsed as unknown as OAuthCredentials;
|
||||
validateOAuthCredentials(oauth);
|
||||
return { ...oauth };
|
||||
}
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isRecord(parsed) || typeof parsed.apiBaseUrl !== "string" || typeof parsed.authorizeKey !== "string") {
|
||||
throw new Error("Credential file is invalid; run setup again");
|
||||
}
|
||||
const authorizeKey = parsed.authorizeKey.trim();
|
||||
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again");
|
||||
return { apiBaseUrl: normalizeApiBaseUrl(parsed.apiBaseUrl), authorizeKey };
|
||||
const authorizeKey = typeof parsed.authorizeKey === "string" ? parsed.authorizeKey.trim() : "";
|
||||
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; sign in again");
|
||||
return { type: "personal-authorize-key", authorizeKey };
|
||||
}
|
||||
|
||||
export function createCredentialProvider(options: CredentialProviderOptions = {}): CredentialProvider {
|
||||
const path = options.path ?? credentialsPath();
|
||||
const now = options.now ?? Date.now;
|
||||
const refresh = options.refresh ?? ((credentials) => refreshOAuthCredentials(credentials));
|
||||
const refreshRetryDelayMs = options.refreshRetryDelayMs ?? 30_000;
|
||||
const refreshSkewMs = options.refreshSkewMs ?? 60_000;
|
||||
let stored: StoredCredentials | undefined;
|
||||
let refreshInFlight: Promise<OAuthCredentials> | undefined;
|
||||
let refreshRetryAt = 0;
|
||||
let generation = 0;
|
||||
|
||||
const provider = async (): Promise<Credentials> => {
|
||||
const requestGeneration = generation;
|
||||
stored ??= loadStoredCredentials(path);
|
||||
if (stored.type === "personal-authorize-key") {
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.authorizeKey };
|
||||
}
|
||||
const currentTime = now();
|
||||
if (stored.expiresAt > currentTime + refreshSkewMs) {
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.accessToken };
|
||||
}
|
||||
if (stored.expiresAt > currentTime && currentTime < refreshRetryAt) {
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.accessToken };
|
||||
}
|
||||
const expiringCredentials = stored;
|
||||
if (!refreshInFlight) {
|
||||
const refreshGeneration = generation;
|
||||
let pending: Promise<OAuthCredentials>;
|
||||
pending = refresh(expiringCredentials)
|
||||
.then((refreshed) => {
|
||||
validateOAuthCredentials(refreshed);
|
||||
if (refreshGeneration === generation) {
|
||||
saveOAuthCredentials(refreshed, path);
|
||||
stored = refreshed;
|
||||
refreshRetryAt = 0;
|
||||
}
|
||||
return refreshed;
|
||||
})
|
||||
.finally(() => {
|
||||
if (refreshInFlight === pending) refreshInFlight = undefined;
|
||||
});
|
||||
refreshInFlight = pending;
|
||||
}
|
||||
try {
|
||||
const refreshed = await refreshInFlight;
|
||||
if (requestGeneration !== generation) return await provider();
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: refreshed.accessToken };
|
||||
} catch (error) {
|
||||
if (requestGeneration !== generation) return await provider();
|
||||
if (expiringCredentials.expiresAt > now()) {
|
||||
refreshRetryAt = now() + refreshRetryDelayMs;
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: expiringCredentials.accessToken };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`OAuth session could not be refreshed; sign in again: ${message}`);
|
||||
}
|
||||
};
|
||||
provider.reset = (): void => {
|
||||
generation += 1;
|
||||
stored = undefined;
|
||||
refreshInFlight = undefined;
|
||||
refreshRetryAt = 0;
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function readHidden(prompt: string): Promise<string> {
|
||||
@@ -112,3 +182,26 @@ export async function readHidden(prompt: string): Promise<string> {
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
function validateOAuthCredentials(credentials: OAuthCredentials): void {
|
||||
if (
|
||||
credentials.type !== "oauth" ||
|
||||
credentials.issuer !== NOCOBASE_OAUTH_ISSUER ||
|
||||
typeof credentials.clientId !== "string" ||
|
||||
!credentials.clientId.trim() ||
|
||||
typeof credentials.accessToken !== "string" ||
|
||||
!credentials.accessToken.trim() ||
|
||||
typeof credentials.refreshToken !== "string" ||
|
||||
!credentials.refreshToken.trim() ||
|
||||
credentials.tokenType !== "Bearer" ||
|
||||
typeof credentials.scope !== "string" ||
|
||||
!credentials.scope.split(/\s+/).includes("api") ||
|
||||
typeof credentials.expiresAt !== "number" ||
|
||||
!Number.isFinite(credentials.expiresAt) ||
|
||||
credentials.expiresAt <= 0 ||
|
||||
(credentials.authorizationSessionId !== undefined &&
|
||||
(typeof credentials.authorizationSessionId !== "string" || !credentials.authorizationSessionId.trim()))
|
||||
) {
|
||||
throw new Error("OAuth credential file is invalid; sign in again");
|
||||
}
|
||||
}
|
||||
|
||||
+144
-73
@@ -29,7 +29,6 @@ import {
|
||||
refFromRow,
|
||||
refIds,
|
||||
refNames,
|
||||
relationIds,
|
||||
relationRefs,
|
||||
relationRows,
|
||||
responseTotal,
|
||||
@@ -127,7 +126,6 @@ export class CrmService {
|
||||
includeRaw?: boolean;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [limit, offset] = normalizePagination(options.limit, options.offset);
|
||||
const [ownerId, ownerError] = await this.resolveOwnerIdOrError(options.owner);
|
||||
if (ownerError) return { error: ownerError };
|
||||
const [teamMemberIds, teamError] = await this.resolveTeamMemberIdsOrError(options.team);
|
||||
@@ -139,18 +137,14 @@ export class CrmService {
|
||||
buildEqCondition(this.config.customer.sourceField, options.source),
|
||||
buildSearchFilter(options.query, this.config.customer.searchFields),
|
||||
]);
|
||||
const result = await this.listWithProjectionFallback(this.config.customer.resource, {
|
||||
limit,
|
||||
offset,
|
||||
return await this.listCompact(this.config.customer.resource, {
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
filter,
|
||||
fields: options.includeRaw ? (this.config.customer.fields ?? []) : this.customerCompactFields(),
|
||||
sort: this.config.customer.sort,
|
||||
compact: (row) => this.compactCustomer(row, false, options.includeRaw),
|
||||
});
|
||||
result.results = (result.results as RecordRow[]).map((row) =>
|
||||
this.compactCustomer(row, false, options.includeRaw),
|
||||
);
|
||||
result.timezone = this.config.timezone;
|
||||
return result;
|
||||
}
|
||||
|
||||
async getCustomer(
|
||||
@@ -208,11 +202,10 @@ export class CrmService {
|
||||
includeRaw?: boolean;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [limit, offset] = normalizePagination(options.limit, options.offset);
|
||||
const includeDetails = options.includeDetails ?? true;
|
||||
const result = await this.listWithProjectionFallback(this.config.contact.resource, {
|
||||
limit,
|
||||
offset,
|
||||
return await this.listCompact(this.config.contact.resource, {
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
filter: buildAndFilter([
|
||||
buildRelationIdCondition(this.config.contact.customerField, options.customerId),
|
||||
buildSearchFilter(options.query, this.config.contact.searchFields),
|
||||
@@ -221,12 +214,8 @@ export class CrmService {
|
||||
? (this.config.contact.fields ?? [])
|
||||
: this.contactCompactFields(includeDetails),
|
||||
sort: this.config.contact.sort,
|
||||
compact: (row) => this.compactContact(row, includeDetails, options.includeRaw),
|
||||
});
|
||||
result.results = (result.results as RecordRow[]).map((row) =>
|
||||
this.compactContact(row, includeDetails, options.includeRaw),
|
||||
);
|
||||
result.timezone = this.config.timezone;
|
||||
return result;
|
||||
}
|
||||
|
||||
async listFollowups(
|
||||
@@ -245,14 +234,13 @@ export class CrmService {
|
||||
includeRaw?: boolean;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [limit, offset] = normalizePagination(options.limit, options.offset);
|
||||
const [ownerId, ownerError] = await this.resolveOwnerIdOrError(options.owner);
|
||||
if (ownerError) return { error: ownerError };
|
||||
const [teamMemberIds, teamError] = await this.resolveTeamMemberIdsOrError(options.team);
|
||||
if (teamError) return { error: teamError };
|
||||
const result = await this.listWithProjectionFallback(this.config.followup.resource, {
|
||||
limit,
|
||||
offset,
|
||||
return await this.listCompact(this.config.followup.resource, {
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
filter: buildAndFilter([
|
||||
buildRelationIdCondition(this.config.followup.customerField, options.customerId),
|
||||
buildRelationIdCondition(this.config.followup.contactField, options.contactId),
|
||||
@@ -265,12 +253,8 @@ export class CrmService {
|
||||
]),
|
||||
fields: options.includeRaw ? (this.config.followup.fields ?? []) : this.followupCompactFields(),
|
||||
sort: this.config.followup.sort,
|
||||
compact: (row) => this.compactFollowup(row, options.includeRaw),
|
||||
});
|
||||
result.results = (result.results as RecordRow[]).map((row) =>
|
||||
this.compactFollowup(row, options.includeRaw),
|
||||
);
|
||||
result.timezone = this.config.timezone;
|
||||
return result;
|
||||
}
|
||||
|
||||
async listProjects(
|
||||
@@ -283,7 +267,6 @@ export class CrmService {
|
||||
includeRaw?: boolean;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [limit, offset] = normalizePagination(options.limit, options.offset);
|
||||
const [teamMemberIds, teamError] = await this.resolveTeamMemberIdsOrError(options.team);
|
||||
if (teamError) return { error: teamError };
|
||||
let stageValue = options.stage;
|
||||
@@ -299,9 +282,9 @@ export class CrmService {
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = await this.listWithProjectionFallback(this.config.project.resource, {
|
||||
limit,
|
||||
offset,
|
||||
return await this.listCompact(this.config.project.resource, {
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
filter: buildAndFilter([
|
||||
buildEqCondition(this.config.project.customerField, options.customerId),
|
||||
buildRelationIdInCondition(
|
||||
@@ -312,30 +295,29 @@ export class CrmService {
|
||||
]),
|
||||
fields: this.projectFields(),
|
||||
sort: ["-updatedAt"],
|
||||
compact: (row) => this.compactProject(row, options.includeRaw),
|
||||
});
|
||||
result.results = (result.results as RecordRow[]).map((row) =>
|
||||
this.compactProject(row, options.includeRaw),
|
||||
);
|
||||
result.timezone = this.config.timezone;
|
||||
return result;
|
||||
}
|
||||
|
||||
async getProject(
|
||||
projectId: unknown,
|
||||
options: { includeContacts?: boolean; includeFollowups?: boolean; includeUpdates?: boolean } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const includeContacts = options.includeContacts ?? true;
|
||||
const row = singleRow(
|
||||
await this.client.getRecord(this.config.project.resource, projectId, { fields: this.projectFields() }),
|
||||
await this.client.getRecord(this.config.project.resource, projectId, {
|
||||
fields: this.projectFields(includeContacts),
|
||||
}),
|
||||
);
|
||||
if (!row)
|
||||
return {
|
||||
error: { code: "project_not_found", message: `No project matched id ${JSON.stringify(projectId)}` },
|
||||
};
|
||||
const result: Record<string, unknown> = {
|
||||
project: this.compactProject(row, false),
|
||||
project: this.compactProject(row, false, false),
|
||||
timezone: this.config.timezone,
|
||||
};
|
||||
if (options.includeContacts ?? true) {
|
||||
if (includeContacts) {
|
||||
result.contacts = relationRefs(row, [this.config.project.contactField], this.contactSpec());
|
||||
}
|
||||
if (options.includeFollowups ?? true) {
|
||||
@@ -421,6 +403,12 @@ export class CrmService {
|
||||
const customerFilter = buildAndFilter([
|
||||
buildRelationIdCondition(this.config.customer.ownerField, ownerId),
|
||||
]);
|
||||
const contactFilter = buildAndFilter([
|
||||
buildRelationIdCondition(
|
||||
[this.config.contact.customerField, this.config.customer.ownerField, "id"],
|
||||
ownerId,
|
||||
),
|
||||
]);
|
||||
const followupFilter = buildAndFilter([
|
||||
buildRelationIdCondition(this.config.followup.ownerField, ownerId),
|
||||
buildDateRangeCondition(this.config.followup.dateField, options.dateFrom, options.dateTo),
|
||||
@@ -428,7 +416,7 @@ export class CrmService {
|
||||
const [customers, contacts, followups, customerStatus, customerSource, customerOwner, recent] =
|
||||
await Promise.all([
|
||||
this.count(this.config.customer.resource, this.config.customer.idField, customerFilter),
|
||||
this.count(this.config.contact.resource, this.config.contact.idField, {}),
|
||||
this.count(this.config.contact.resource, this.config.contact.idField, contactFilter),
|
||||
this.count(this.config.followup.resource, this.config.followup.idField, followupFilter),
|
||||
this.groupCount(
|
||||
this.config.customer.resource,
|
||||
@@ -704,6 +692,12 @@ export class CrmService {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(asOf) || Number.isNaN(new Date(`${asOf}T00:00:00Z`).getTime())) {
|
||||
return { error: { code: "invalid_date", message: "as_of must use YYYY-MM-DD" } };
|
||||
}
|
||||
const followupFilter = buildAndFilter([
|
||||
buildRelationIdCondition(this.config.followup.ownerField, ownerId),
|
||||
buildRelationIdInCondition(this.config.followup.ownerField, teamMemberIds),
|
||||
{ [this.config.followup.dateField]: { $dateNotAfter: asOf } },
|
||||
{ [this.config.followup.statusField]: { $ne: INVALID_FOLLOWUP_STATUS } },
|
||||
]);
|
||||
const [customers, followups] = await Promise.all([
|
||||
this.scanRecords(this.config.customer.resource, {
|
||||
filter: buildAndFilter([
|
||||
@@ -713,38 +707,19 @@ export class CrmService {
|
||||
fields: this.customerCompactFields(),
|
||||
sort: this.config.customer.sort,
|
||||
}),
|
||||
this.scanRecords(this.config.followup.resource, {
|
||||
filter: buildAndFilter([
|
||||
buildRelationIdCondition(this.config.followup.ownerField, ownerId),
|
||||
buildRelationIdInCondition(this.config.followup.ownerField, teamMemberIds),
|
||||
{ [this.config.followup.dateField]: { $dateNotAfter: asOf } },
|
||||
{ [this.config.followup.statusField]: { $ne: INVALID_FOLLOWUP_STATUS } },
|
||||
]),
|
||||
fields: [
|
||||
this.config.followup.idField,
|
||||
`${this.config.followup.customerField}.${this.config.customer.idField}`,
|
||||
this.config.followup.dateField,
|
||||
this.config.followup.statusField,
|
||||
],
|
||||
sort: this.config.followup.sort,
|
||||
}),
|
||||
this.latestFollowupsByCustomer(followupFilter),
|
||||
]);
|
||||
const lastFollowup = new Map<unknown, Date>();
|
||||
const lastFollowup = new Map<string, Date>();
|
||||
for (const followup of followups.rows) {
|
||||
const date = parseDate(firstText(followup, [this.config.followup.dateField, ...FOLLOWUP_DATE_KEYS]));
|
||||
if (!date) continue;
|
||||
for (const customerId of relationIds(
|
||||
followup[this.config.followup.customerField],
|
||||
this.config.customer.idField,
|
||||
)) {
|
||||
const previous = lastFollowup.get(customerId);
|
||||
if (!previous || date > previous) lastFollowup.set(customerId, date);
|
||||
}
|
||||
const customerId = followup.customer_id;
|
||||
const date = parseDate(firstText(followup, ["last_followup_at"]));
|
||||
if (customerId === undefined || customerId === null || !date) continue;
|
||||
lastFollowup.set(String(customerId), date);
|
||||
}
|
||||
const asOfDate = new Date(`${asOf}T00:00:00Z`);
|
||||
const stale = customers.rows
|
||||
.map((customer) => {
|
||||
const last = lastFollowup.get(customer[this.config.customer.idField]);
|
||||
const last = lastFollowup.get(String(customer[this.config.customer.idField]));
|
||||
const days = last
|
||||
? Math.floor((asOfDate.getTime() - dateOnlyUtc(last, this.config.timezone).getTime()) / 86_400_000)
|
||||
: null;
|
||||
@@ -767,7 +742,21 @@ export class CrmService {
|
||||
);
|
||||
});
|
||||
const result = paginatedRows(stale.slice(offset, offset + limit), stale.length, limit, offset);
|
||||
Object.assign(result, { as_of: asOf, inactive_days: inactiveDays, timezone: this.config.timezone });
|
||||
const totalIsExact = !customers.truncation && !followups.truncation;
|
||||
Object.assign(result, {
|
||||
as_of: asOf,
|
||||
inactive_days: inactiveDays,
|
||||
timezone: this.config.timezone,
|
||||
total_is_exact: totalIsExact,
|
||||
});
|
||||
if (!totalIsExact) {
|
||||
const scannedStaleCount = result.total;
|
||||
delete result.total;
|
||||
Object.assign(result, {
|
||||
scanned_stale_count: scannedStaleCount,
|
||||
pagination_scope: "scanned_records_only",
|
||||
});
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const truncation: Record<string, unknown> = {};
|
||||
if (customers.truncation) {
|
||||
@@ -775,7 +764,7 @@ export class CrmService {
|
||||
truncation.customers = customers.truncation;
|
||||
}
|
||||
if (followups.truncation) {
|
||||
warnings.push("follow-up scan was truncated");
|
||||
warnings.push("follow-up customer aggregation was truncated");
|
||||
truncation.followups = followups.truncation;
|
||||
}
|
||||
if (warnings.length > 0) Object.assign(result, { data_quality_warnings: warnings, truncation });
|
||||
@@ -858,6 +847,60 @@ export class CrmService {
|
||||
}
|
||||
}
|
||||
|
||||
private async latestFollowupsByCustomer(filter: Filter): Promise<ScanResult> {
|
||||
const followup = this.config.followup;
|
||||
const rows: RecordRow[] = [];
|
||||
let offset = 0;
|
||||
const query = async (limit: number, queryOffset: number): Promise<RecordRow[]> =>
|
||||
dataRows(
|
||||
await this.client.queryRecords(followup.resource, {
|
||||
dimensions: [
|
||||
{
|
||||
field: [followup.customerField, this.config.customer.idField],
|
||||
alias: "customer_id",
|
||||
},
|
||||
],
|
||||
measures: [
|
||||
{
|
||||
field: [followup.dateField],
|
||||
aggregation: "max",
|
||||
alias: "last_followup_at",
|
||||
},
|
||||
],
|
||||
orders: [
|
||||
{
|
||||
field: [followup.customerField, this.config.customer.idField],
|
||||
alias: "customer_id",
|
||||
order: "asc",
|
||||
},
|
||||
],
|
||||
filter,
|
||||
limit,
|
||||
offset: queryOffset,
|
||||
timezone: this.config.timezone,
|
||||
}),
|
||||
);
|
||||
|
||||
while (rows.length < MAX_SCAN_RECORDS) {
|
||||
const batchLimit = Math.min(MAX_PAGE_SIZE, MAX_SCAN_RECORDS - rows.length);
|
||||
const batch = await query(batchLimit, offset);
|
||||
rows.push(...batch);
|
||||
if (batch.length < batchLimit) return { rows };
|
||||
offset += batch.length;
|
||||
}
|
||||
const overflow = await query(1, offset);
|
||||
if (overflow.length === 0) return { rows };
|
||||
return {
|
||||
rows,
|
||||
truncation: {
|
||||
minimum_total: rows.length + 1,
|
||||
returned: rows.length,
|
||||
limit: MAX_SCAN_RECORDS,
|
||||
next_offset: offset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private decorateStatusCounts(rows: RecordRow[]): RecordRow[] {
|
||||
return decorateDimensionCounts(
|
||||
rows,
|
||||
@@ -923,6 +966,30 @@ export class CrmService {
|
||||
);
|
||||
}
|
||||
|
||||
private async listCompact(
|
||||
resource: string,
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: Filter;
|
||||
fields: string[];
|
||||
sort?: string[] | null;
|
||||
compact: (row: RecordRow) => Record<string, unknown>;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [limit, offset] = normalizePagination(options.limit, options.offset);
|
||||
const result = await this.listWithProjectionFallback(resource, {
|
||||
limit,
|
||||
offset,
|
||||
filter: options.filter,
|
||||
fields: options.fields,
|
||||
sort: options.sort,
|
||||
});
|
||||
result.results = (result.results as RecordRow[]).map(options.compact);
|
||||
result.timezone = this.config.timezone;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async listWithProjectionFallback(
|
||||
resource: string,
|
||||
options: {
|
||||
@@ -1222,7 +1289,7 @@ export class CrmService {
|
||||
}
|
||||
}
|
||||
|
||||
private projectFields(): string[] {
|
||||
private projectFields(includeContacts = true): string[] {
|
||||
const project = this.config.project;
|
||||
return fieldsWithProjections(
|
||||
[
|
||||
@@ -1241,12 +1308,16 @@ export class CrmService {
|
||||
],
|
||||
[
|
||||
...this.projection(project.customerRelationField, this.customerSpec()),
|
||||
...this.projection(project.contactField, this.contactSpec()),
|
||||
...(includeContacts ? this.projection(project.contactField, this.contactSpec()) : []),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private compactProject(row: RecordRow, includeRaw = false): Record<string, unknown> {
|
||||
private compactProject(
|
||||
row: RecordRow,
|
||||
includeRaw = false,
|
||||
includeContacts = true,
|
||||
): Record<string, unknown> {
|
||||
const project = this.config.project;
|
||||
const progress = row[project.progressField];
|
||||
const progressLabels = Object.fromEntries(
|
||||
@@ -1266,7 +1337,7 @@ export class CrmService {
|
||||
part_numbers: row[project.partNumbersField],
|
||||
period_usage: row[project.periodUsageField],
|
||||
details: row[project.detailsField],
|
||||
contacts: relationRefs(row, [project.contactField], this.contactSpec()),
|
||||
...(includeContacts ? { contacts: relationRefs(row, [project.contactField], this.contactSpec()) } : {}),
|
||||
createdAt: toZonedIso(row.createdAt, this.config.timezone),
|
||||
updatedAt: toZonedIso(row.updatedAt, this.config.timezone),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const NOCOBASE_API_BASE_URL = "https://justconn.top/api";
|
||||
export const NOCOBASE_OAUTH_ISSUER = NOCOBASE_API_BASE_URL;
|
||||
export const NOCOBASE_OAUTH_DISCOVERY_URL = `${NOCOBASE_OAUTH_ISSUER}/.well-known/openid-configuration`;
|
||||
export const NOCOBASE_OAUTH_RESOURCE = `${NOCOBASE_API_BASE_URL}/`;
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
NOCOBASE_OAUTH_DISCOVERY_URL,
|
||||
NOCOBASE_OAUTH_ISSUER,
|
||||
NOCOBASE_OAUTH_RESOURCE,
|
||||
} from "./nocobase-endpoints.js";
|
||||
import { isRecord } from "./types.js";
|
||||
|
||||
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
const REQUESTED_SCOPE = "api offline_access";
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface OAuthCredentials {
|
||||
type: "oauth";
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: "Bearer";
|
||||
scope: string;
|
||||
expiresAt: number;
|
||||
authorizationSessionId?: string;
|
||||
}
|
||||
|
||||
interface OAuthDiscovery {
|
||||
issuer: string;
|
||||
deviceAuthorizationEndpoint: string;
|
||||
registrationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
}
|
||||
|
||||
interface DeviceAuthorization {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete?: string;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export interface AuthorizationPrompt {
|
||||
opened: boolean;
|
||||
url: string;
|
||||
userCode: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface BrowserLoginOptions {
|
||||
clientId?: string;
|
||||
fetch?: typeof fetch;
|
||||
now?: () => number;
|
||||
onAuthorization?: (prompt: AuthorizationPrompt) => void;
|
||||
openBrowser?: (url: string) => Promise<boolean>;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PendingOAuthAuthorization {
|
||||
schemaVersion: 1;
|
||||
type: "oauth-device-authorization";
|
||||
sessionId: string;
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
deviceCode: string;
|
||||
prompt: AuthorizationPrompt;
|
||||
expiresAt: number;
|
||||
intervalSeconds: number;
|
||||
nextPollAt: number;
|
||||
}
|
||||
|
||||
export type BrowserLoginPollResult =
|
||||
| { status: "authorization_pending"; authorization: PendingOAuthAuthorization }
|
||||
| { status: "authenticated"; credentials: OAuthCredentials };
|
||||
|
||||
export interface PollBrowserLoginOptions {
|
||||
fetch?: typeof fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface RefreshOptions {
|
||||
fetch?: typeof fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class OAuthProtocolError extends Error {
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.name = "OAuthProtocolError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithBrowser(options: BrowserLoginOptions = {}): Promise<OAuthCredentials> {
|
||||
const started = await beginDeviceLogin(options);
|
||||
return await pollForTokens(started.discovery, started.clientId, started.authorization, {
|
||||
fetch: options.fetch ?? fetch,
|
||||
now: options.now ?? Date.now,
|
||||
sleep: options.sleep ?? defaultSleep,
|
||||
});
|
||||
}
|
||||
|
||||
export async function beginBrowserLogin(
|
||||
options: BrowserLoginOptions = {},
|
||||
): Promise<PendingOAuthAuthorization> {
|
||||
const now = options.now ?? Date.now;
|
||||
const started = await beginDeviceLogin(options);
|
||||
const createdAt = now();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: randomUUID(),
|
||||
issuer: NOCOBASE_OAUTH_ISSUER,
|
||||
clientId: started.clientId,
|
||||
deviceCode: started.authorization.deviceCode,
|
||||
prompt: started.prompt,
|
||||
expiresAt: createdAt + started.authorization.expiresIn * 1_000,
|
||||
intervalSeconds: started.authorization.interval,
|
||||
nextPollAt: createdAt + started.authorization.interval * 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pollBrowserLogin(
|
||||
value: unknown,
|
||||
options: PollBrowserLoginOptions = {},
|
||||
): Promise<BrowserLoginPollResult> {
|
||||
const authorization = validatePendingOAuthAuthorization(value);
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const now = options.now ?? Date.now;
|
||||
const currentTime = now();
|
||||
if (currentTime >= authorization.expiresAt) throw expiredAuthorization();
|
||||
if (currentTime < authorization.nextPollAt) {
|
||||
return { status: "authorization_pending", authorization };
|
||||
}
|
||||
const discovery = await discoverOAuth(fetchImpl);
|
||||
const result = await pollDeviceTokenOnce(
|
||||
discovery,
|
||||
authorization.clientId,
|
||||
authorization.deviceCode,
|
||||
fetchImpl,
|
||||
);
|
||||
if (result.status === "authenticated") {
|
||||
return {
|
||||
status: "authenticated",
|
||||
credentials: tokenCredentials(result.payload, authorization.clientId, now, {
|
||||
requireRefreshToken: true,
|
||||
authorizationSessionId: authorization.sessionId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (result.status === "expired_token") throw expiredAuthorization();
|
||||
const intervalSeconds =
|
||||
result.status === "slow_down"
|
||||
? Math.min(authorization.intervalSeconds + 5, 30)
|
||||
: authorization.intervalSeconds;
|
||||
return {
|
||||
status: "authorization_pending",
|
||||
authorization: nextAuthorizationPoll(authorization, now(), intervalSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePendingOAuthAuthorization(value: unknown): PendingOAuthAuthorization {
|
||||
if (!isRecord(value) || !isRecord(value.prompt)) throw invalidPendingAuthorization();
|
||||
const prompt = value.prompt;
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
value.type !== "oauth-device-authorization" ||
|
||||
value.issuer !== NOCOBASE_OAUTH_ISSUER ||
|
||||
!nonEmptyString(value.sessionId) ||
|
||||
!nonEmptyString(value.clientId) ||
|
||||
!nonEmptyString(value.deviceCode) ||
|
||||
typeof prompt.opened !== "boolean" ||
|
||||
!nonEmptyString(prompt.url) ||
|
||||
!nonEmptyString(prompt.userCode) ||
|
||||
!positiveFinite(prompt.expiresIn) ||
|
||||
!positiveFinite(value.expiresAt) ||
|
||||
!positiveFinite(value.intervalSeconds) ||
|
||||
value.intervalSeconds > 30 ||
|
||||
typeof value.nextPollAt !== "number" ||
|
||||
!Number.isFinite(value.nextPollAt) ||
|
||||
value.nextPollAt <= 0
|
||||
) {
|
||||
throw invalidPendingAuthorization();
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: value.sessionId.trim(),
|
||||
issuer: NOCOBASE_OAUTH_ISSUER,
|
||||
clientId: value.clientId.trim(),
|
||||
deviceCode: value.deviceCode.trim(),
|
||||
prompt: {
|
||||
opened: prompt.opened,
|
||||
url: trustedBrowserUrl(prompt.url).href,
|
||||
userCode: prompt.userCode.trim(),
|
||||
expiresIn: prompt.expiresIn,
|
||||
},
|
||||
expiresAt: value.expiresAt,
|
||||
intervalSeconds: value.intervalSeconds,
|
||||
nextPollAt: value.nextPollAt,
|
||||
};
|
||||
}
|
||||
|
||||
interface StartedDeviceLogin {
|
||||
discovery: OAuthDiscovery;
|
||||
clientId: string;
|
||||
authorization: DeviceAuthorization;
|
||||
prompt: AuthorizationPrompt;
|
||||
}
|
||||
|
||||
async function beginDeviceLogin(options: BrowserLoginOptions): Promise<StartedDeviceLogin> {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const discovery = await discoverOAuth(fetchImpl);
|
||||
let clientId = options.clientId?.trim();
|
||||
if (!clientId) clientId = await registerPublicClient(discovery, fetchImpl);
|
||||
|
||||
let authorization: DeviceAuthorization;
|
||||
try {
|
||||
authorization = await startDeviceAuthorization(discovery, clientId, fetchImpl);
|
||||
} catch (error) {
|
||||
if (!(error instanceof OAuthProtocolError) || error.code !== "invalid_client" || !options.clientId) {
|
||||
throw error;
|
||||
}
|
||||
clientId = await registerPublicClient(discovery, fetchImpl);
|
||||
authorization = await startDeviceAuthorization(discovery, clientId, fetchImpl);
|
||||
}
|
||||
|
||||
const browserUrl = trustedBrowserUrl(
|
||||
authorization.verificationUriComplete ?? authorization.verificationUri,
|
||||
).href;
|
||||
const opened = await (options.openBrowser ?? openExternalUrl)(browserUrl);
|
||||
const prompt = {
|
||||
opened,
|
||||
url: browserUrl,
|
||||
userCode: authorization.userCode,
|
||||
expiresIn: authorization.expiresIn,
|
||||
};
|
||||
options.onAuthorization?.(prompt);
|
||||
|
||||
return {
|
||||
discovery,
|
||||
clientId,
|
||||
authorization,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshOAuthCredentials(
|
||||
credentials: OAuthCredentials,
|
||||
options: RefreshOptions = {},
|
||||
): Promise<OAuthCredentials> {
|
||||
if (credentials.issuer !== NOCOBASE_OAUTH_ISSUER) {
|
||||
throw new OAuthProtocolError("Stored OAuth issuer is not the configured NocoBase instance");
|
||||
}
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const discovery = await discoverOAuth(fetchImpl);
|
||||
const form = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: credentials.clientId,
|
||||
refresh_token: credentials.refreshToken,
|
||||
resource: NOCOBASE_OAUTH_RESOURCE,
|
||||
});
|
||||
const payload = await requestJson(
|
||||
discovery.tokenEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: formHeaders(),
|
||||
body: form,
|
||||
},
|
||||
fetchImpl,
|
||||
[credentials.refreshToken, credentials.accessToken],
|
||||
);
|
||||
return tokenCredentials(payload, credentials.clientId, options.now ?? Date.now, {
|
||||
refreshToken: credentials.refreshToken,
|
||||
scope: credentials.scope,
|
||||
authorizationSessionId: credentials.authorizationSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<boolean> {
|
||||
const target = trustedBrowserUrl(url).href;
|
||||
const command = browserCommand(target);
|
||||
return await new Promise<boolean>((resolveValue) => {
|
||||
let settled = false;
|
||||
const settle = (value: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveValue(value);
|
||||
};
|
||||
try {
|
||||
const child = spawn(command.executable, command.args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.once("error", () => settle(false));
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
settle(true);
|
||||
});
|
||||
} catch {
|
||||
settle(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverOAuth(fetchImpl: typeof fetch): Promise<OAuthDiscovery> {
|
||||
const payload = await requestJson(
|
||||
NOCOBASE_OAUTH_DISCOVERY_URL,
|
||||
{ method: "GET", headers: { accept: "application/json" } },
|
||||
fetchImpl,
|
||||
);
|
||||
if (!isRecord(payload) || payload.issuer !== NOCOBASE_OAUTH_ISSUER) {
|
||||
throw new OAuthProtocolError("NocoBase OAuth discovery returned an unexpected issuer");
|
||||
}
|
||||
requireArrayValue(payload, "grant_types_supported", DEVICE_GRANT);
|
||||
requireArrayValue(payload, "grant_types_supported", "refresh_token");
|
||||
requireArrayValue(payload, "scopes_supported", "api");
|
||||
requireArrayValue(payload, "scopes_supported", "offline_access");
|
||||
requireArrayValue(payload, "token_endpoint_auth_methods_supported", "none");
|
||||
return {
|
||||
issuer: payload.issuer,
|
||||
deviceAuthorizationEndpoint: trustedApiEndpoint(stringField(payload, "device_authorization_endpoint"))
|
||||
.href,
|
||||
registrationEndpoint: trustedApiEndpoint(stringField(payload, "registration_endpoint")).href,
|
||||
tokenEndpoint: trustedApiEndpoint(stringField(payload, "token_endpoint")).href,
|
||||
};
|
||||
}
|
||||
|
||||
async function registerPublicClient(discovery: OAuthDiscovery, fetchImpl: typeof fetch): Promise<string> {
|
||||
const payload = await requestJson(
|
||||
discovery.registrationEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "NocoBase CRM MCP",
|
||||
application_type: "native",
|
||||
grant_types: [DEVICE_GRANT, "refresh_token"],
|
||||
response_types: [],
|
||||
redirect_uris: [],
|
||||
token_endpoint_auth_method: "none",
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
if (!isRecord(payload)) throw new OAuthProtocolError("OAuth client registration returned invalid JSON");
|
||||
const clientId = stringField(payload, "client_id").trim();
|
||||
if (!clientId) throw new OAuthProtocolError("OAuth client registration returned no client ID");
|
||||
if (typeof payload.client_secret === "string" && payload.client_secret) {
|
||||
throw new OAuthProtocolError("NocoBase registered an unexpected confidential OAuth client");
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
async function startDeviceAuthorization(
|
||||
discovery: OAuthDiscovery,
|
||||
clientId: string,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<DeviceAuthorization> {
|
||||
const form = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
scope: REQUESTED_SCOPE,
|
||||
resource: NOCOBASE_OAUTH_RESOURCE,
|
||||
});
|
||||
const payload = await requestJson(
|
||||
discovery.deviceAuthorizationEndpoint,
|
||||
{ method: "POST", headers: formHeaders(), body: form },
|
||||
fetchImpl,
|
||||
);
|
||||
if (!isRecord(payload)) throw new OAuthProtocolError("Device authorization returned invalid JSON");
|
||||
const expiresIn = positiveNumber(payload.expires_in, "expires_in");
|
||||
const intervalValue = payload.interval === undefined ? 5 : positiveNumber(payload.interval, "interval");
|
||||
return {
|
||||
deviceCode: stringField(payload, "device_code"),
|
||||
userCode: stringField(payload, "user_code"),
|
||||
verificationUri: trustedBrowserUrl(stringField(payload, "verification_uri")).href,
|
||||
verificationUriComplete:
|
||||
typeof payload.verification_uri_complete === "string"
|
||||
? trustedBrowserUrl(payload.verification_uri_complete).href
|
||||
: undefined,
|
||||
expiresIn,
|
||||
interval: Math.min(Math.max(intervalValue, 1), 30),
|
||||
};
|
||||
}
|
||||
|
||||
async function pollForTokens(
|
||||
discovery: OAuthDiscovery,
|
||||
clientId: string,
|
||||
authorization: DeviceAuthorization,
|
||||
options: Required<Pick<BrowserLoginOptions, "fetch" | "now" | "sleep">>,
|
||||
): Promise<OAuthCredentials> {
|
||||
const deadline = options.now() + authorization.expiresIn * 1_000;
|
||||
let intervalSeconds = authorization.interval;
|
||||
while (options.now() < deadline) {
|
||||
await options.sleep(intervalSeconds * 1_000);
|
||||
const result = await pollDeviceTokenOnce(discovery, clientId, authorization.deviceCode, options.fetch);
|
||||
if (result.status === "authenticated") {
|
||||
return tokenCredentials(result.payload, clientId, options.now, { requireRefreshToken: true });
|
||||
}
|
||||
if (result.status === "slow_down") intervalSeconds = Math.min(intervalSeconds + 5, 30);
|
||||
if (result.status === "expired_token") break;
|
||||
}
|
||||
throw expiredAuthorization();
|
||||
}
|
||||
|
||||
type DeviceTokenPollResult =
|
||||
| { status: "authenticated"; payload: unknown }
|
||||
| { status: "authorization_pending" | "slow_down" | "expired_token" };
|
||||
|
||||
async function pollDeviceTokenOnce(
|
||||
discovery: OAuthDiscovery,
|
||||
clientId: string,
|
||||
deviceCode: string,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<DeviceTokenPollResult> {
|
||||
try {
|
||||
return {
|
||||
status: "authenticated",
|
||||
payload: await requestDeviceToken(discovery, clientId, deviceCode, fetchImpl),
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof OAuthProtocolError &&
|
||||
["authorization_pending", "slow_down", "expired_token"].includes(error.code ?? "")
|
||||
) {
|
||||
return { status: error.code as "authorization_pending" | "slow_down" | "expired_token" };
|
||||
}
|
||||
if (error instanceof OAuthProtocolError && error.code === "access_denied") {
|
||||
throw new OAuthProtocolError("NocoBase browser authorization was cancelled", error.code);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestDeviceToken(
|
||||
discovery: OAuthDiscovery,
|
||||
clientId: string,
|
||||
deviceCode: string,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<unknown> {
|
||||
const form = new URLSearchParams({
|
||||
grant_type: DEVICE_GRANT,
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
resource: NOCOBASE_OAUTH_RESOURCE,
|
||||
});
|
||||
return await requestJson(
|
||||
discovery.tokenEndpoint,
|
||||
{ method: "POST", headers: formHeaders(), body: form },
|
||||
fetchImpl,
|
||||
[deviceCode],
|
||||
);
|
||||
}
|
||||
|
||||
function tokenCredentials(
|
||||
payload: unknown,
|
||||
clientId: string,
|
||||
now: () => number,
|
||||
fallback: {
|
||||
refreshToken?: string;
|
||||
scope?: string;
|
||||
requireRefreshToken?: boolean;
|
||||
authorizationSessionId?: string;
|
||||
},
|
||||
): OAuthCredentials {
|
||||
if (!isRecord(payload)) throw new OAuthProtocolError("OAuth token endpoint returned invalid JSON");
|
||||
const accessToken = stringField(payload, "access_token");
|
||||
const tokenType = stringField(payload, "token_type");
|
||||
if (tokenType.toLowerCase() !== "bearer") {
|
||||
throw new OAuthProtocolError("NocoBase returned an unsupported OAuth token type");
|
||||
}
|
||||
const refreshToken =
|
||||
typeof payload.refresh_token === "string" && payload.refresh_token.trim()
|
||||
? payload.refresh_token.trim()
|
||||
: fallback.refreshToken;
|
||||
if (!refreshToken && fallback.requireRefreshToken) {
|
||||
throw new OAuthProtocolError("NocoBase did not issue an offline refresh token");
|
||||
}
|
||||
if (!refreshToken) throw new OAuthProtocolError("Stored OAuth session has no refresh token");
|
||||
const scope =
|
||||
typeof payload.scope === "string" && payload.scope.trim() ? payload.scope.trim() : (fallback.scope ?? "");
|
||||
if (!scope.split(/\s+/).includes("api")) {
|
||||
throw new OAuthProtocolError("NocoBase did not grant the required API scope");
|
||||
}
|
||||
return {
|
||||
type: "oauth",
|
||||
issuer: NOCOBASE_OAUTH_ISSUER,
|
||||
clientId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType: "Bearer",
|
||||
scope,
|
||||
expiresAt: now() + positiveNumber(payload.expires_in, "expires_in") * 1_000,
|
||||
...(fallback.authorizationSessionId ? { authorizationSessionId: fallback.authorizationSessionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fetchImpl: typeof fetch,
|
||||
secrets: string[] = [],
|
||||
): Promise<unknown> {
|
||||
const endpoint = trustedApiEndpoint(url);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
timeout.unref?.();
|
||||
try {
|
||||
const response = await fetchImpl(endpoint, {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload: unknown = {};
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new OAuthProtocolError("NocoBase OAuth endpoint returned invalid JSON");
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : undefined;
|
||||
const description =
|
||||
isRecord(payload) && typeof payload.error_description === "string"
|
||||
? redact(payload.error_description, secrets)
|
||||
: undefined;
|
||||
throw new OAuthProtocolError(
|
||||
description || code || `NocoBase OAuth request failed with HTTP ${response.status}`,
|
||||
code,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthProtocolError) throw error;
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OAuthProtocolError("NocoBase OAuth request timed out");
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new OAuthProtocolError(`NocoBase OAuth request failed: ${redact(message, secrets)}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function trustedApiEndpoint(value: string | URL): URL {
|
||||
const url = new URL(value);
|
||||
const expected = new URL(NOCOBASE_OAUTH_ISSUER);
|
||||
if (url.protocol !== "https:" || url.origin !== expected.origin || !url.pathname.startsWith("/api/")) {
|
||||
throw new OAuthProtocolError("NocoBase OAuth metadata referenced an untrusted endpoint");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function trustedBrowserUrl(value: string): URL {
|
||||
const url = new URL(value);
|
||||
const expected = new URL(NOCOBASE_OAUTH_ISSUER);
|
||||
if (url.protocol !== "https:" || url.origin !== expected.origin) {
|
||||
throw new OAuthProtocolError("NocoBase OAuth returned an untrusted browser URL");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function formHeaders(): Record<string, string> {
|
||||
return {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, key: string): string {
|
||||
const field = value[key];
|
||||
if (typeof field !== "string" || !field.trim()) {
|
||||
throw new OAuthProtocolError(`NocoBase OAuth response has no ${key}`);
|
||||
}
|
||||
return field.trim();
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown, key: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new OAuthProtocolError(`NocoBase OAuth response has invalid ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveFinite(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && Boolean(value.trim());
|
||||
}
|
||||
|
||||
function nextAuthorizationPoll(
|
||||
authorization: PendingOAuthAuthorization,
|
||||
currentTime: number,
|
||||
intervalSeconds: number,
|
||||
): PendingOAuthAuthorization {
|
||||
return {
|
||||
...authorization,
|
||||
intervalSeconds,
|
||||
nextPollAt: currentTime + intervalSeconds * 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidPendingAuthorization(): OAuthProtocolError {
|
||||
return new OAuthProtocolError("Stored browser authorization is invalid; start a new login");
|
||||
}
|
||||
|
||||
function expiredAuthorization(): OAuthProtocolError {
|
||||
return new OAuthProtocolError("NocoBase browser authorization expired; start a new login", "expired_token");
|
||||
}
|
||||
|
||||
function requireArrayValue(payload: Record<string, unknown>, key: string, expected: string): void {
|
||||
const values = payload[key];
|
||||
if (!Array.isArray(values) || !values.includes(expected)) {
|
||||
throw new OAuthProtocolError(`NocoBase OAuth server does not support ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function redact(message: string, secrets: string[]): string {
|
||||
return secrets.filter(Boolean).reduce((result, secret) => result.replaceAll(secret, "<redacted>"), message);
|
||||
}
|
||||
|
||||
function browserCommand(url: string): { executable: string; args: string[] } {
|
||||
if (process.platform === "darwin") return { executable: "open", args: [url] };
|
||||
if (process.platform === "win32") {
|
||||
return { executable: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
||||
}
|
||||
return { executable: "xdg-open", args: [url] };
|
||||
}
|
||||
|
||||
async function defaultSleep(milliseconds: number): Promise<void> {
|
||||
await new Promise<void>((resolveValue) => setTimeout(resolveValue, milliseconds));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
fchmodSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export function readPrivateJson(path: string, label: string): unknown {
|
||||
if (process.platform !== "win32") {
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} file must be private (0600); current mode is ${mode.toString(8)}`);
|
||||
}
|
||||
}
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
export function writePrivateJson(path: string, value: unknown, label: string): void {
|
||||
ensurePrivateDirectory(path, label);
|
||||
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
const descriptor = openSync(temporaryPath, "wx", 0o600);
|
||||
try {
|
||||
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
||||
writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
renameSync(temporaryPath, path);
|
||||
} catch (error) {
|
||||
try {
|
||||
unlinkSync(temporaryPath);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePrivateDirectory(path: string, label: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== "win32") {
|
||||
const directoryMode = statSync(dirname(path)).mode & 0o777;
|
||||
if ((directoryMode & 0o077) !== 0) {
|
||||
throw new Error(`${label} directory permissions are too broad: ${directoryMode.toString(8)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removePrivateFile(path: string): void {
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
+134
-125
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
import type { AuthService } from "./auth-service.js";
|
||||
import type { CrmService } from "./crm-service.js";
|
||||
import { NocoBaseError } from "./nocobase-client.js";
|
||||
import type { ProjectService } from "./project-service.js";
|
||||
@@ -16,17 +17,41 @@ const pagination = {
|
||||
};
|
||||
|
||||
export function createServer(options: {
|
||||
authService: AuthService;
|
||||
crmService: CrmService;
|
||||
projectService?: ProjectService;
|
||||
readonly?: boolean;
|
||||
enableConfigTools?: boolean;
|
||||
}): McpServer {
|
||||
const server = new McpServer({ name: "nocobase-crm", version: VERSION });
|
||||
const auth = options.authService;
|
||||
const crm = options.crmService;
|
||||
register(
|
||||
server,
|
||||
"nocobase_login",
|
||||
"Start or reuse resumable NocoBase OAuth device sign-in. When authorization is pending, you MUST deliver login_url and user_code verbatim to the requesting user in the current private conversation, including on a headless server. They are user-verification data, not a password, API key, bearer token, or device_code; remote desktop is not required. Never post them publicly or send them to a third party.",
|
||||
{
|
||||
force: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Start a fresh login even when stored credentials exist; use after crm_health rejects them.",
|
||||
),
|
||||
},
|
||||
({ force }) => auth.start({ force }),
|
||||
writeAnnotations,
|
||||
);
|
||||
register(
|
||||
server,
|
||||
"nocobase_login_status",
|
||||
"Poll or resume NocoBase device sign-in after MCP restarts. A pending result repeats the same user-facing login_url, user_code, and private-delivery policy; reuse it after retry_after_seconds instead of creating another login.",
|
||||
{},
|
||||
() => auth.status(),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
"crm_health",
|
||||
"Check NocoBase reachability, personal Key authentication, and CRM mapping.",
|
||||
"Check NocoBase reachability, user authentication, and CRM mapping.",
|
||||
{},
|
||||
() => crm.crmHealth(),
|
||||
);
|
||||
@@ -44,7 +69,10 @@ export function createServer(options: {
|
||||
{
|
||||
date_from: z.string().optional().describe("Optional lower follow-up date bound."),
|
||||
date_to: z.string().optional().describe("Optional upper follow-up date bound."),
|
||||
owner: z.string().optional().describe("Optional user ownership and participation filter."),
|
||||
owner: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional user filter: owned customers, their contacts, and participated follow-ups."),
|
||||
},
|
||||
({ date_from, date_to, owner }) => crm.crmOverview({ dateFrom: date_from, dateTo: date_to, owner }),
|
||||
);
|
||||
@@ -131,18 +159,16 @@ export function createServer(options: {
|
||||
include_raw: z.boolean().default(false),
|
||||
},
|
||||
(args) =>
|
||||
withRawPolicy(args.include_raw, () =>
|
||||
crm.listCustomers({
|
||||
query: args.query,
|
||||
status: args.status,
|
||||
owner: args.owner,
|
||||
team: args.team,
|
||||
source: args.source,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
),
|
||||
crm.listCustomers({
|
||||
query: args.query,
|
||||
status: args.status,
|
||||
owner: args.owner,
|
||||
team: args.team,
|
||||
source: args.source,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
@@ -156,14 +182,12 @@ export function createServer(options: {
|
||||
include_raw: z.boolean().default(false),
|
||||
},
|
||||
(args) =>
|
||||
withRawPolicy(args.include_raw, () =>
|
||||
crm.getCustomer(args.customer_id, {
|
||||
includeContacts: args.include_contacts,
|
||||
includeRecentFollowups: args.include_recent_followups,
|
||||
followupLimit: args.followup_limit,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
),
|
||||
crm.getCustomer(args.customer_id, {
|
||||
includeContacts: args.include_contacts,
|
||||
includeRecentFollowups: args.include_recent_followups,
|
||||
followupLimit: args.followup_limit,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
@@ -177,16 +201,14 @@ export function createServer(options: {
|
||||
include_raw: z.boolean().default(false),
|
||||
},
|
||||
(args) =>
|
||||
withRawPolicy(args.include_raw, () =>
|
||||
crm.listContacts({
|
||||
customerId: args.customer_id,
|
||||
query: args.query,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeDetails: args.include_details,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
),
|
||||
crm.listContacts({
|
||||
customerId: args.customer_id,
|
||||
query: args.query,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeDetails: args.include_details,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
@@ -206,22 +228,20 @@ export function createServer(options: {
|
||||
include_raw: z.boolean().default(false),
|
||||
},
|
||||
(args) =>
|
||||
withRawPolicy(args.include_raw, () =>
|
||||
crm.listFollowups({
|
||||
customerId: args.customer_id,
|
||||
contactId: args.contact_id,
|
||||
owner: args.owner,
|
||||
team: args.team,
|
||||
projectId: args.project_id,
|
||||
followupType: args.followup_type,
|
||||
recordStatus: args.record_status,
|
||||
dateFrom: args.date_from,
|
||||
dateTo: args.date_to,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
),
|
||||
crm.listFollowups({
|
||||
customerId: args.customer_id,
|
||||
contactId: args.contact_id,
|
||||
owner: args.owner,
|
||||
team: args.team,
|
||||
projectId: args.project_id,
|
||||
followupType: args.followup_type,
|
||||
recordStatus: args.record_status,
|
||||
dateFrom: args.date_from,
|
||||
dateTo: args.date_to,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
@@ -235,16 +255,14 @@ export function createServer(options: {
|
||||
include_raw: z.boolean().default(false),
|
||||
},
|
||||
(args) =>
|
||||
withRawPolicy(args.include_raw, () =>
|
||||
crm.listProjects({
|
||||
customerId: args.customer_id,
|
||||
team: args.team,
|
||||
stage: args.stage,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
),
|
||||
crm.listProjects({
|
||||
customerId: args.customer_id,
|
||||
team: args.team,
|
||||
stage: args.stage,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
includeRaw: args.include_raw,
|
||||
}),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
@@ -302,76 +320,66 @@ export function createServer(options: {
|
||||
}
|
||||
|
||||
function registerWriteTools(server: McpServer, projects: ProjectService): void {
|
||||
server.registerTool(
|
||||
register(
|
||||
server,
|
||||
"create_project",
|
||||
"Create one fixed-schema project and optionally associate uploaded attachment IDs.",
|
||||
{
|
||||
description: "Create one fixed-schema project and optionally associate uploaded attachment IDs.",
|
||||
inputSchema: {
|
||||
name: z.string().describe("Project name; must not be blank."),
|
||||
customer_id: z.union([z.string(), z.number()]).optional(),
|
||||
inquiry_date: z.string().optional(),
|
||||
progress: z.string().optional(),
|
||||
applications: z.string().optional(),
|
||||
part_numbers: z.string().optional(),
|
||||
period_usage: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
name: z.string().describe("Project name; must not be blank."),
|
||||
customer_id: z.union([z.string(), z.number()]).optional(),
|
||||
inquiry_date: z.string().optional(),
|
||||
progress: z.string().optional(),
|
||||
applications: z.string().optional(),
|
||||
part_numbers: z.string().optional(),
|
||||
period_usage: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
},
|
||||
async (args) =>
|
||||
toolResult(
|
||||
await safeTool(() =>
|
||||
projects.createProject({
|
||||
name: args.name,
|
||||
customerId: args.customer_id,
|
||||
inquiryDate: args.inquiry_date,
|
||||
progress: args.progress,
|
||||
applications: args.applications,
|
||||
partNumbers: args.part_numbers,
|
||||
periodUsage: args.period_usage,
|
||||
details: args.details,
|
||||
attachmentIds: args.attachment_ids,
|
||||
}),
|
||||
),
|
||||
),
|
||||
(args) =>
|
||||
projects.createProject({
|
||||
name: args.name,
|
||||
customerId: args.customer_id,
|
||||
inquiryDate: args.inquiry_date,
|
||||
progress: args.progress,
|
||||
applications: args.applications,
|
||||
partNumbers: args.part_numbers,
|
||||
periodUsage: args.period_usage,
|
||||
details: args.details,
|
||||
attachmentIds: args.attachment_ids,
|
||||
}),
|
||||
writeAnnotations,
|
||||
);
|
||||
server.registerTool(
|
||||
register(
|
||||
server,
|
||||
"update_project",
|
||||
"Update fixed project fields and incrementally add or remove attachment IDs.",
|
||||
{
|
||||
description: "Update fixed project fields and incrementally add or remove attachment IDs.",
|
||||
inputSchema: {
|
||||
project_id: z.union([z.string(), z.number()]),
|
||||
name: z.string().optional(),
|
||||
customer_id: z.union([z.string(), z.number()]).optional(),
|
||||
inquiry_date: z.string().optional(),
|
||||
progress: z.string().optional(),
|
||||
applications: z.string().optional(),
|
||||
part_numbers: z.string().optional(),
|
||||
period_usage: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
add_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
remove_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
project_id: z.union([z.string(), z.number()]),
|
||||
name: z.string().optional(),
|
||||
customer_id: z.union([z.string(), z.number()]).optional(),
|
||||
inquiry_date: z.string().optional(),
|
||||
progress: z.string().optional(),
|
||||
applications: z.string().optional(),
|
||||
part_numbers: z.string().optional(),
|
||||
period_usage: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
add_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
remove_attachment_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
},
|
||||
async (args) =>
|
||||
toolResult(
|
||||
await safeTool(() =>
|
||||
projects.updateProject(args.project_id, {
|
||||
name: args.name,
|
||||
customerId: args.customer_id,
|
||||
inquiryDate: args.inquiry_date,
|
||||
progress: args.progress,
|
||||
applications: args.applications,
|
||||
partNumbers: args.part_numbers,
|
||||
periodUsage: args.period_usage,
|
||||
details: args.details,
|
||||
addAttachmentIds: args.add_attachment_ids,
|
||||
removeAttachmentIds: args.remove_attachment_ids,
|
||||
}),
|
||||
),
|
||||
),
|
||||
(args) =>
|
||||
projects.updateProject(args.project_id, {
|
||||
name: args.name,
|
||||
customerId: args.customer_id,
|
||||
inquiryDate: args.inquiry_date,
|
||||
progress: args.progress,
|
||||
applications: args.applications,
|
||||
partNumbers: args.part_numbers,
|
||||
periodUsage: args.period_usage,
|
||||
details: args.details,
|
||||
addAttachmentIds: args.add_attachment_ids,
|
||||
removeAttachmentIds: args.remove_attachment_ids,
|
||||
}),
|
||||
writeAnnotations,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,9 +389,10 @@ function register(
|
||||
description: string,
|
||||
inputSchema: z.ZodRawShape,
|
||||
operation: (args: any) => Promise<ToolPayload>,
|
||||
annotations = readAnnotations,
|
||||
): void {
|
||||
server.registerTool(name, { description, inputSchema, annotations: readAnnotations }, async (args: any) =>
|
||||
toolResult(await safeTool(() => operation(args))),
|
||||
server.registerTool(name, { description, inputSchema, annotations }, async (args: any) =>
|
||||
toolResult(await safeTool(() => withRawPolicy(args.include_raw === true, () => operation(args)))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -413,7 +422,7 @@ async function safeTool(operation: () => Promise<ToolPayload>): Promise<ToolPayl
|
||||
error: {
|
||||
code: "nocobase_auth_failed",
|
||||
message: error.message,
|
||||
hint: "Ask the local installation Agent to run setup, then enter a personal Authorize Key in the hidden prompt.",
|
||||
hint: "Call nocobase_login (force=true if stored credentials were rejected), deliver its login_url and user_code to the requesting user in the current private conversation exactly as instructed, then poll nocobase_login_status. Headless servers do not require remote desktop.",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
@@ -423,7 +432,7 @@ async function safeTool(operation: () => Promise<ToolPayload>): Promise<ToolPayl
|
||||
error: {
|
||||
code: error.statusCode === 408 ? "nocobase_request_timeout" : "nocobase_request_failed",
|
||||
message: error.message,
|
||||
hint: "Check the NocoBase URL, ACL, collection mapping, and query parameters.",
|
||||
hint: "Check justconn.top availability, ACL, collection mapping, and query parameters.",
|
||||
retryable: error.retryable,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { NocoBaseAuthService } from "../src/auth-service.js";
|
||||
import { AuthorizationStore, pendingAuthorizationPath } from "../src/authorization-store.js";
|
||||
import { loadStoredCredentials, saveCredentials } from "../src/credentials.js";
|
||||
import type { BrowserLoginPollResult, OAuthCredentials, PendingOAuthAuthorization } from "../src/oauth.js";
|
||||
|
||||
const oauthCredentials: OAuthCredentials = {
|
||||
type: "oauth",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
accessToken: "private-access-token",
|
||||
refreshToken: "private-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expiresAt: 901_000,
|
||||
};
|
||||
|
||||
test("returns a browser link, then polls and saves OAuth credentials without exposing secrets", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-service-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
let currentTime = 1_000;
|
||||
let polls = 0;
|
||||
let verified = 0;
|
||||
let reloaded = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => currentTime,
|
||||
beginLogin: async () => pendingAuthorization(),
|
||||
pollLogin: async (): Promise<BrowserLoginPollResult> => {
|
||||
polls += 1;
|
||||
return { status: "authenticated", credentials: oauthCredentials };
|
||||
},
|
||||
verifyCredentials: async (value) => {
|
||||
assert.equal(value.accessToken, oauthCredentials.accessToken);
|
||||
verified += 1;
|
||||
},
|
||||
onCredentialsSaved: () => {
|
||||
reloaded += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const started = await service.start();
|
||||
assert.deepEqual(started, pendingPayload());
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(started),
|
||||
/private-access-token|private-refresh-token|private-device-code/,
|
||||
);
|
||||
assert.equal(existsSync(path), false);
|
||||
assert.equal(existsSync(pendingPath), true);
|
||||
if (process.platform !== "win32") assert.equal(statSync(pendingPath).mode & 0o777, 0o600);
|
||||
assert.equal((await service.status()).status, "authorization_pending");
|
||||
assert.equal(polls, 0);
|
||||
|
||||
currentTime = 6_000;
|
||||
assert.equal((await service.status()).status, "authenticated");
|
||||
assert.equal(polls, 1);
|
||||
assert.equal(verified, 1);
|
||||
assert.equal(reloaded, 1);
|
||||
assert.equal(existsSync(pendingPath), false);
|
||||
const stored = loadStoredCredentials(path);
|
||||
assert.equal(stored.type, "oauth");
|
||||
if (stored.type === "oauth") {
|
||||
assert.equal(stored.authorizationSessionId, "authorization-session");
|
||||
assert.equal(stored.accessToken, oauthCredentials.accessToken);
|
||||
}
|
||||
if (process.platform !== "win32") assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test("a new service instance resumes and completes the same pending browser authorization", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-restart-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const first = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => pendingAuthorization(),
|
||||
});
|
||||
const started = await first.start();
|
||||
assert.equal(started.status, "authorization_pending");
|
||||
|
||||
let starts = 0;
|
||||
let polls = 0;
|
||||
const restarted = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 6_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return pendingAuthorization();
|
||||
},
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return { status: "authenticated", credentials: oauthCredentials };
|
||||
},
|
||||
verifyCredentials: async () => undefined,
|
||||
});
|
||||
const resumed = await restarted.status();
|
||||
assert.equal(resumed.status, "authenticated");
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(polls, 1);
|
||||
assert.doesNotMatch(JSON.stringify(resumed), /private-device-code|private-access-token/);
|
||||
assert.equal(existsSync(pendingAuthorizationPath(path)), false);
|
||||
assert.equal(loadStoredCredentials(path).type, "oauth");
|
||||
});
|
||||
|
||||
test("a new OS process resumes and completes the same pending browser authorization", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-child-restart-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const authServiceUrl = pathToFileURL(resolve("src/auth-service.ts")).href;
|
||||
const first = runNodeModule(`
|
||||
const { NocoBaseAuthService } = await import(${JSON.stringify(authServiceUrl)});
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: ${JSON.stringify(path)},
|
||||
now: () => 1000,
|
||||
beginLogin: async () => (${JSON.stringify(pendingAuthorization())})
|
||||
});
|
||||
process.stdout.write(JSON.stringify(await service.start()));
|
||||
`);
|
||||
assert.equal(JSON.parse(first).status, "authorization_pending");
|
||||
assert.equal(existsSync(pendingAuthorizationPath(path)), true);
|
||||
|
||||
const second = runNodeModule(`
|
||||
const { NocoBaseAuthService } = await import(${JSON.stringify(authServiceUrl)});
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: ${JSON.stringify(path)},
|
||||
now: () => 6000,
|
||||
pollLogin: async () => ({
|
||||
status: "authenticated",
|
||||
credentials: ${JSON.stringify(oauthCredentials)}
|
||||
}),
|
||||
verifyCredentials: async () => undefined
|
||||
});
|
||||
process.stdout.write(JSON.stringify(await service.status()));
|
||||
`);
|
||||
assert.equal(JSON.parse(second).status, "authenticated");
|
||||
assert.equal(existsSync(pendingAuthorizationPath(path)), false);
|
||||
const stored = loadStoredCredentials(path);
|
||||
assert.equal(stored.type, "oauth");
|
||||
if (stored.type === "oauth") {
|
||||
assert.equal(stored.authorizationSessionId, "authorization-session");
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps exchanged credentials when post-login verification is temporarily unavailable", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-verify-failure-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingPath);
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => ({ status: "authenticated", credentials: oauthCredentials }),
|
||||
verifyCredentials: async () => {
|
||||
throw new Error("temporary health check failure");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.status();
|
||||
assert.equal(result.status, "authenticated");
|
||||
assert.equal(result.credential_verification, "failed");
|
||||
assert.match(String((result.verification_error as Record<string, unknown>).message), /temporary/);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result),
|
||||
/private-access-token|private-refresh-token|private-device-code/,
|
||||
);
|
||||
assert.equal(existsSync(pendingPath), false);
|
||||
assert.equal(loadStoredCredentials(path).type, "oauth");
|
||||
|
||||
const restarted = new NocoBaseAuthService({ credentialsFile: path });
|
||||
assert.equal((await restarted.status()).status, "authenticated");
|
||||
});
|
||||
|
||||
test("reuses stored login and an already pending forced login", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-force-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveCredentials({ authorizeKey: "personal-key" }, path);
|
||||
let starts = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return pendingAuthorization();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authenticated");
|
||||
assert.equal(starts, 0);
|
||||
assert.equal((await service.start({ force: true })).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
assert.equal((await service.start({ force: true })).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test("coalesces concurrent login starts across MCP service instances", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-start-concurrent-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
let starts = 0;
|
||||
let resolveStart: (authorization: PendingOAuthAuthorization) => void = () => undefined;
|
||||
const started = new Promise<PendingOAuthAuthorization>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
const options = {
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return await started;
|
||||
},
|
||||
};
|
||||
const firstService = new NocoBaseAuthService(options);
|
||||
const secondService = new NocoBaseAuthService(options);
|
||||
|
||||
const first = firstService.start();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const second = secondService.start();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(starts, 1);
|
||||
resolveStart(pendingAuthorization());
|
||||
const results = await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.login_url),
|
||||
[
|
||||
"https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
"https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
],
|
||||
);
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test("removes an expired pending authorization instead of creating another link", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-expired-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
savePendingAuthorization(pendingAuthorization({ expiresAt: 999, nextPollAt: 999 }), pendingPath);
|
||||
let polls = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return { status: "authorization_pending", authorization: pendingAuthorization() };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.status();
|
||||
assert.equal(result.status, "authorization_failed");
|
||||
assert.equal(polls, 0);
|
||||
assert.equal(existsSync(pendingPath), false);
|
||||
assert.doesNotMatch(JSON.stringify(result), /private-device-code/);
|
||||
});
|
||||
|
||||
test("stored credentials supersede an earlier in-memory authorization failure", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-disk-truth-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
savePendingAuthorization(
|
||||
pendingAuthorization({ expiresAt: 999, nextPollAt: 999 }),
|
||||
pendingAuthorizationPath(path),
|
||||
);
|
||||
const service = new NocoBaseAuthService({ credentialsFile: path, now: () => 1_000 });
|
||||
|
||||
assert.equal((await service.status()).status, "authorization_failed");
|
||||
saveCredentials({ authorizeKey: "new-personal-key" }, path);
|
||||
assert.equal((await service.status()).status, "authenticated");
|
||||
});
|
||||
|
||||
test("coalesces concurrent status polls for one pending authorization", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-concurrent-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingAuthorizationPath(path));
|
||||
let polls = 0;
|
||||
let resolvePoll: (result: BrowserLoginPollResult) => void = () => undefined;
|
||||
const poll = new Promise<BrowserLoginPollResult>((resolve) => {
|
||||
resolvePoll = resolve;
|
||||
});
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return await poll;
|
||||
},
|
||||
verifyCredentials: async () => undefined,
|
||||
});
|
||||
|
||||
const first = service.status();
|
||||
const second = service.status();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(polls, 1);
|
||||
resolvePoll({ status: "authenticated", credentials: oauthCredentials });
|
||||
const results = await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.status),
|
||||
["authenticated", "authenticated"],
|
||||
);
|
||||
});
|
||||
|
||||
test("prevents duplicate status polls across MCP service instances", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-poll-concurrent-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingAuthorizationPath(path));
|
||||
let polls = 0;
|
||||
let resolvePoll: (result: BrowserLoginPollResult) => void = () => undefined;
|
||||
const poll = new Promise<BrowserLoginPollResult>((resolve) => {
|
||||
resolvePoll = resolve;
|
||||
});
|
||||
const options = {
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return await poll;
|
||||
},
|
||||
verifyCredentials: async () => undefined,
|
||||
};
|
||||
const firstService = new NocoBaseAuthService(options);
|
||||
const secondService = new NocoBaseAuthService(options);
|
||||
|
||||
const first = firstService.status();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const second = await secondService.status();
|
||||
assert.equal(polls, 1);
|
||||
assert.equal(second.status, "authorization_pending");
|
||||
assert.equal(second.poll_in_progress, true);
|
||||
assert.doesNotMatch(JSON.stringify(second), /private-device-code|private-access-token/);
|
||||
|
||||
resolvePoll({ status: "authenticated", credentials: oauthCredentials });
|
||||
assert.equal((await first).status, "authenticated");
|
||||
assert.equal((await secondService.status()).status, "authenticated");
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test("reclaims an authorization lock owned by a dead process", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-dead-lock-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
const lockPath = `${pendingPath}.lock`;
|
||||
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(lockPath, JSON.stringify({ pid: 2_147_483_647, createdAt: 1_000, token: "dead-owner" }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
let starts = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 2_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return pendingAuthorization();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
test("reclaims a malformed authorization lock only after it becomes stale", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-malformed-lock-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
const lockPath = `${pendingPath}.lock`;
|
||||
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(lockPath, "not-json", { mode: 0o600 });
|
||||
utimesSync(lockPath, new Date(0), new Date(0));
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 200_000,
|
||||
beginLogin: async () => pendingAuthorization(),
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authorization_pending");
|
||||
assert.equal(existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
test("a lock holder never removes a replacement lock owned by another process", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-lock-owner-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const lockPath = `${pendingAuthorizationPath(path)}.lock`;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => {
|
||||
writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: process.pid, createdAt: 1_000, token: "replacement-owner" }),
|
||||
);
|
||||
return pendingAuthorization();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authorization_pending");
|
||||
assert.equal(JSON.parse(readFileSync(lockPath, "utf8")).token, "replacement-owner");
|
||||
});
|
||||
|
||||
function pendingAuthorization(overrides: Partial<PendingOAuthAuthorization> = {}): PendingOAuthAuthorization {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: "authorization-session",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
deviceCode: "private-device-code",
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
userCode: "ABCD-EFGH",
|
||||
expiresIn: 600,
|
||||
},
|
||||
expiresAt: 601_000,
|
||||
intervalSeconds: 5,
|
||||
nextPollAt: 6_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingPayload(): Record<string, unknown> {
|
||||
return {
|
||||
status: "authorization_pending",
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
user_code: "ABCD-EFGH",
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
expires_in_seconds: 600,
|
||||
retry_after_seconds: 5,
|
||||
browser_opened: false,
|
||||
next_action:
|
||||
"Deliver login_url and user_code verbatim to the requesting user once. On a headless or remote server, send them in the current private conversation: they are OAuth device user-verification data, not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party. Then wait retry_after_seconds and call nocobase_login_status. Reuse this authorization; do not ask the user to log in again.",
|
||||
};
|
||||
}
|
||||
|
||||
function runNodeModule(source: string): string {
|
||||
const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", source], {
|
||||
cwd: resolve("."),
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
assert.equal(child.status, 0, child.stderr || child.error?.message);
|
||||
return child.stdout;
|
||||
}
|
||||
|
||||
function savePendingAuthorization(authorization: PendingOAuthAuthorization, path: string): void {
|
||||
new AuthorizationStore(path).save(authorization);
|
||||
}
|
||||
+170
-8
@@ -3,19 +3,131 @@ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { clientConfig, runCli } from "../src/cli.js";
|
||||
import { clientConfig, runCli, setupAuthorizationEvent } from "../src/cli.js";
|
||||
import { VERSION } from "../src/version.js";
|
||||
|
||||
test("package, manifest, and runtime versions agree with no runtime npm dependencies", () => {
|
||||
test("package, manifest, and runtime versions agree with commit-pinned installation", () => {
|
||||
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
const manifest = JSON.parse(readFileSync(new URL("../install-manifest.json", import.meta.url), "utf8"));
|
||||
assert.equal(VERSION, "0.3.0");
|
||||
assert.equal(VERSION, "0.4.0");
|
||||
assert.equal(packageJson.version, VERSION);
|
||||
assert.equal(manifest.version, VERSION);
|
||||
assert.equal(manifest.schemaVersion, 3);
|
||||
assert.equal(manifest.distribution.branch, "main");
|
||||
assert.equal(manifest.distribution.strategy, "resolve-latest-commit-then-pin");
|
||||
assert.equal(manifest.distribution.releaseRequired, false);
|
||||
assert.equal(manifest.distribution.releaseRef, undefined);
|
||||
assert.equal(manifest.installation.temporarySourceArchiveBuild, true);
|
||||
assert.equal(manifest.installation.fallbackToRelease, false);
|
||||
assert.equal(manifest.installation.supportsInPlaceUpdate, true);
|
||||
assert.equal(manifest.installation.sameCommitBuildSkip, true);
|
||||
assert.deepEqual(manifest.installation.installReceipt, {
|
||||
path: "<install-prefix>/install-receipt.json",
|
||||
schemaVersion: 1,
|
||||
posixFileMode: "0600",
|
||||
containsSecrets: false,
|
||||
});
|
||||
assert.deepEqual(manifest.installation.operationDetection, [
|
||||
"fresh-install",
|
||||
"in-place-update",
|
||||
"repair",
|
||||
"already-current",
|
||||
]);
|
||||
assert.equal(manifest.entrypoints.guidedCommand, "client-config");
|
||||
assert.equal(manifest.entrypoints.mcpLoginTool, "nocobase_login");
|
||||
assert.equal(manifest.entrypoints.mcpLoginStatusTool, "nocobase_login_status");
|
||||
assert.equal(manifest.authentication.processRestartResumable, true);
|
||||
assert.equal(
|
||||
manifest.authentication.pendingAuthorizationStorage,
|
||||
"short-lived per-user private local file",
|
||||
);
|
||||
assert.equal(manifest.authentication.allowDeviceCodeExposure, false);
|
||||
assert.deepEqual(manifest.authentication.userVerificationDelivery, {
|
||||
fields: ["login_url", "verification_uri_complete", "user_code"],
|
||||
intendedRecipient: "requesting-user",
|
||||
channel: "current-private-conversation",
|
||||
shareableWithIntendedRecipient: true,
|
||||
publicSharingAllowed: false,
|
||||
containsBearerCredentials: false,
|
||||
containsDeviceCode: false,
|
||||
remoteDesktopRequired: false,
|
||||
});
|
||||
assert.deepEqual(packageJson.dependencies, undefined);
|
||||
assert.equal(manifest.runtime.runtimeNpmDependencies, 0);
|
||||
});
|
||||
|
||||
test("installation and update protocol resolves main once and never falls back to an old Release", () => {
|
||||
const guide = readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8");
|
||||
const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
||||
const installUrl = "https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md";
|
||||
assert.match(guide, /branches\/main/);
|
||||
assert.match(guide, /40 位十六进制完整 SHA/);
|
||||
assert.match(guide, /resolve-latest-commit-then-pin/);
|
||||
assert.match(guide, /installation\.supportsInPlaceUpdate=true/);
|
||||
assert.match(guide, /installation\.sameCommitBuildSkip=true/);
|
||||
assert.match(guide, /archive\/<commit-sha>\.zip/);
|
||||
assert.match(guide, /archive\/<commit-sha>\.tar\.gz/);
|
||||
assert.match(guide, /Windows 使用 `.zip`/);
|
||||
assert.match(guide, /不得无条件调用.*TarFile\.extractall/);
|
||||
assert.match(guide, /inspect\.signature/);
|
||||
assert.match(guide, /extractfile\(\)/);
|
||||
assert.match(guide, /不得直接调用不带过滤器的 `extractall\(\)`/);
|
||||
assert.match(guide, /不需要重新解析 `main`、重新下载归档或中断用户/);
|
||||
assert.match(guide, /不得.*退回旧 Release/);
|
||||
assert.match(guide, /client-config \[--config <existing-config-path>\] \[--enable-writes\] --json/);
|
||||
assert.match(guide, /nocobase_login\(\{\}\)/);
|
||||
assert.match(guide, /stdio 进程.*退出或重启/);
|
||||
assert.match(guide, /下一次 `nocobase_login_status`.*恢复同一个链接/);
|
||||
assert.match(guide, /判断首次安装、更新或已有最新版/);
|
||||
assert.match(guide, /install-receipt\.json/);
|
||||
assert.match(guide, /保留.*CRM.*配置.*个人凭据.*只读\/写入模式/);
|
||||
assert.match(guide, /先调用 `crm_health`/);
|
||||
assert.match(guide, /不得仅因更新而要求用户重新登录/);
|
||||
assert.match(guide, /不得以“链接包含一次性凭据”为由拒绝发送/);
|
||||
assert.match(guide, /不得要求用户操作服务器远程桌面/);
|
||||
assert.ok(guide.includes(installUrl));
|
||||
assert.ok(readme.includes(installUrl));
|
||||
assert.match(readme, /帮我安装或更新 NocoBase CRM MCP/);
|
||||
assert.match(readme, /相同 Commit 不会重复构建/);
|
||||
assert.match(readme, /只有认证确实缺失或失效时才发起登录/);
|
||||
assert.match(readme, /无界面或远程环境也要发到当前私聊/);
|
||||
assert.match(readme, /不要要求远程桌面/);
|
||||
assert.match(readme, /nocobase_login/);
|
||||
const promptStart = readme.indexOf("请按以下 INSTALL.md");
|
||||
const promptEnd = readme.indexOf(installUrl, promptStart) + installUrl.length;
|
||||
assert.ok(promptStart >= 0 && promptEnd > promptStart);
|
||||
assert.ok([...readme.slice(promptStart, promptEnd)].length < 350);
|
||||
assert.doesNotMatch(guide, /下载本文件所在的同一固定 Release/);
|
||||
assert.doesNotMatch(readme, /<main 分支的 INSTALL\.md 链接>/);
|
||||
});
|
||||
|
||||
test("headless setup emits user-deliverable OAuth verification metadata", () => {
|
||||
const event = setupAuthorizationEvent({
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
userCode: "TEST-CODE",
|
||||
expiresIn: 600,
|
||||
});
|
||||
assert.equal(event.event, "authorization_required");
|
||||
assert.equal(event.authorization_type, "oauth_device_user_verification");
|
||||
assert.equal(event.login_url, "https://justconn.top/idpOAuth/device?user_code=TEST-CODE");
|
||||
assert.equal(event.verification_uri_complete, event.login_url);
|
||||
assert.equal(event.user_code, "TEST-CODE");
|
||||
assert.equal(event.intended_recipient, "requesting_user");
|
||||
assert.equal(event.delivery_channel, "current_private_conversation");
|
||||
assert.equal(event.shareable_with_requesting_user, true);
|
||||
assert.equal(event.private_conversation_required, true);
|
||||
assert.equal(event.public_sharing_allowed, false);
|
||||
assert.equal(event.contains_bearer_credentials, false);
|
||||
assert.equal(event.contains_device_code, false);
|
||||
assert.equal(event.remote_desktop_required, false);
|
||||
assert.match(String(event.next_action), /current private conversation/);
|
||||
assert.match(String(event.next_action), /remote desktop is not required/);
|
||||
assert.equal(Object.hasOwn(event, "access_token"), false);
|
||||
assert.equal(Object.hasOwn(event, "refresh_token"), false);
|
||||
assert.equal(Object.hasOwn(event, "device_code"), false);
|
||||
});
|
||||
|
||||
test("generated MCP config contains no Key, token, or environment secret", () => {
|
||||
const config = clientConfig("/private/config.json", false);
|
||||
const serialized = JSON.stringify(config);
|
||||
@@ -24,17 +136,14 @@ test("generated MCP config contains no Key, token, or environment secret", () =>
|
||||
assert.doesNotMatch(serialized, /authorize|token|secret|password/i);
|
||||
});
|
||||
|
||||
test("setup refuses a non-TTY before creating credential files", async () => {
|
||||
test("manual Key fallback refuses a non-TTY before creating credential files", async () => {
|
||||
if (process.stdin.isTTY || process.stderr.isTTY) return;
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-nontty-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
const previous = process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
process.env.NOCOBASE_MCP_CREDENTIALS = path;
|
||||
try {
|
||||
await assert.rejects(
|
||||
runCli(["setup", "--api-base-url", "https://nb.example", "--json"]),
|
||||
/local terminal/,
|
||||
);
|
||||
await assert.rejects(runCli(["setup", "--manual-key", "--json"]), /local terminal/);
|
||||
assert.equal(existsSync(path), false);
|
||||
assert.equal(existsSync(join(directory, "config.json")), false);
|
||||
} finally {
|
||||
@@ -42,3 +151,56 @@ test("setup refuses a non-TTY before creating credential files", async () => {
|
||||
else process.env.NOCOBASE_MCP_CREDENTIALS = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("setup rejects a missing explicit config before starting browser authorization", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-setup-preflight-"));
|
||||
const credentials = join(directory, "credentials.json");
|
||||
const missingConfig = join(directory, "missing-config.json");
|
||||
const previousCredentials = process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
process.env.NOCOBASE_MCP_CREDENTIALS = credentials;
|
||||
try {
|
||||
await assert.rejects(
|
||||
runCli(["setup", "--config", missingConfig, "--no-browser", "--json"]),
|
||||
/Config file does not exist/,
|
||||
);
|
||||
assert.equal(existsSync(credentials), false);
|
||||
} finally {
|
||||
if (previousCredentials === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
else process.env.NOCOBASE_MCP_CREDENTIALS = previousCredentials;
|
||||
}
|
||||
});
|
||||
|
||||
test("client-config prepares the bundled default config before login", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-client-config-"));
|
||||
const credentials = join(directory, "credentials.json");
|
||||
const config = join(directory, "config.json");
|
||||
const previousCredentials = process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
const previousConfig = process.env.NOCOBASE_MCP_CONFIG;
|
||||
const originalWrite = process.stdout.write;
|
||||
let output = "";
|
||||
process.env.NOCOBASE_MCP_CREDENTIALS = credentials;
|
||||
delete process.env.NOCOBASE_MCP_CONFIG;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await runCli(["client-config", "--json"]);
|
||||
assert.equal(existsSync(config), true);
|
||||
assert.equal(existsSync(credentials), false);
|
||||
assert.equal(JSON.parse(output).mcpServers["nocobase-crm"].args.includes("--config"), true);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
if (previousCredentials === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
else process.env.NOCOBASE_MCP_CREDENTIALS = previousCredentials;
|
||||
if (previousConfig === undefined) delete process.env.NOCOBASE_MCP_CONFIG;
|
||||
else process.env.NOCOBASE_MCP_CONFIG = previousConfig;
|
||||
}
|
||||
});
|
||||
|
||||
test("setup rejects API endpoint overrides", async () => {
|
||||
await assert.rejects(
|
||||
runCli(["setup", "--api-base-url", "https://other.example"]),
|
||||
/Unknown option: --api-base-url/,
|
||||
);
|
||||
});
|
||||
|
||||
+111
-13
@@ -1,33 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { DEFAULT_CONFIG, loadConfig } from "../src/config.js";
|
||||
import { loadCredentials, normalizeApiBaseUrl, saveCredentials } from "../src/credentials.js";
|
||||
import {
|
||||
createCredentialProvider,
|
||||
loadCredentials,
|
||||
loadStoredCredentials,
|
||||
NOCOBASE_API_BASE_URL,
|
||||
saveCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "../src/credentials.js";
|
||||
|
||||
test("normalizes application URLs to the NocoBase API base", () => {
|
||||
assert.equal(normalizeApiBaseUrl("https://nb.example/app/"), "https://nb.example/app/api");
|
||||
assert.equal(normalizeApiBaseUrl("http://127.0.0.1:13000/api"), "http://127.0.0.1:13000/api");
|
||||
assert.throws(() => normalizeApiBaseUrl("http://nb.example"), /HTTPS/i);
|
||||
assert.throws(() => normalizeApiBaseUrl("https://user:pass@nb.example"), /credentials/i);
|
||||
});
|
||||
|
||||
test("stores and reads credentials from a private file", () => {
|
||||
test("stores only the Key and always loads the fixed NocoBase endpoint", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credentials-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveCredentials({ apiBaseUrl: "https://nb.example", authorizeKey: "secret" }, path);
|
||||
saveCredentials({ authorizeKey: "secret" }, path);
|
||||
assert.deepEqual(loadCredentials(path), {
|
||||
apiBaseUrl: "https://nb.example/api",
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey: "secret",
|
||||
});
|
||||
assert.match(readFileSync(path, "utf8"), /secret/);
|
||||
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { authorizeKey: "secret" });
|
||||
|
||||
writeFileSync(path, JSON.stringify({ apiBaseUrl: "https://legacy.example/api", authorizeKey: "secret" }));
|
||||
assert.equal(loadCredentials(path).apiBaseUrl, NOCOBASE_API_BASE_URL);
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(path, 0o644);
|
||||
assert.throws(() => loadCredentials(path), /0600/);
|
||||
}
|
||||
});
|
||||
|
||||
test("stores OAuth credentials privately and refreshes them once for concurrent requests", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-oauth-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveOAuthCredentials(
|
||||
{
|
||||
type: "oauth",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
tokenType: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expiresAt: 900,
|
||||
},
|
||||
path,
|
||||
);
|
||||
assert.equal(loadCredentials(path).authorizeKey, "old-access");
|
||||
let refreshes = 0;
|
||||
const provider = createCredentialProvider({
|
||||
path,
|
||||
now: () => 1_000,
|
||||
refresh: async (credentials) => {
|
||||
refreshes += 1;
|
||||
await Promise.resolve();
|
||||
return { ...credentials, accessToken: "new-access", refreshToken: "new-refresh", expiresAt: 100_000 };
|
||||
},
|
||||
});
|
||||
const results = await Promise.all([provider(), provider(), provider()]);
|
||||
assert.equal(refreshes, 1);
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.authorizeKey),
|
||||
["new-access", "new-access", "new-access"],
|
||||
);
|
||||
const stored = loadStoredCredentials(path);
|
||||
assert.equal(stored.type, "oauth");
|
||||
if (stored.type === "oauth") {
|
||||
assert.equal(stored.accessToken, "new-access");
|
||||
assert.equal(stored.refreshToken, "new-refresh");
|
||||
}
|
||||
if (process.platform !== "win32") assert.equal(statMode(path), 0o600);
|
||||
});
|
||||
|
||||
test("credential provider can reload credentials saved by browser login without restarting MCP", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credential-reload-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveCredentials({ authorizeKey: "old-key" }, path);
|
||||
const provider = createCredentialProvider({ path });
|
||||
assert.equal((await provider()).authorizeKey, "old-key");
|
||||
|
||||
saveCredentials({ authorizeKey: "new-key" }, path);
|
||||
assert.equal((await provider()).authorizeKey, "old-key");
|
||||
provider.reset();
|
||||
assert.equal((await provider()).authorizeKey, "new-key");
|
||||
});
|
||||
|
||||
test("backs off after a refresh failure while the current access token remains valid", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-oauth-backoff-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveOAuthCredentials(
|
||||
{
|
||||
type: "oauth",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
accessToken: "still-valid-access",
|
||||
refreshToken: "refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expiresAt: 50_000,
|
||||
},
|
||||
path,
|
||||
);
|
||||
let currentTime = 10_000;
|
||||
let refreshes = 0;
|
||||
const provider = createCredentialProvider({
|
||||
path,
|
||||
now: () => currentTime,
|
||||
refresh: async () => {
|
||||
refreshes += 1;
|
||||
throw new Error("temporary network failure");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await provider()).authorizeKey, "still-valid-access");
|
||||
assert.equal((await provider()).authorizeKey, "still-valid-access");
|
||||
assert.equal(refreshes, 1);
|
||||
|
||||
currentTime += 30_000;
|
||||
assert.equal((await provider()).authorizeKey, "still-valid-access");
|
||||
assert.equal(refreshes, 2);
|
||||
});
|
||||
|
||||
test("loads camelCase and snake_case config aliases without mutating defaults", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-config-"));
|
||||
const path = join(directory, "config.json");
|
||||
@@ -54,3 +148,7 @@ test("rejects malformed config types before any NocoBase request", () => {
|
||||
writeFileSync(path, JSON.stringify({ collections: { customers: { fields: "id,name" } } }));
|
||||
assert.throws(() => loadConfig(path), /array of strings/);
|
||||
});
|
||||
|
||||
function statMode(path: string): number {
|
||||
return statSync(path).mode & 0o777;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,38 @@ test("resolves owner candidates and returns ambiguity as structured data", async
|
||||
assert.equal((listed.error as Record<string, unknown>).code, "owner_ambiguous");
|
||||
});
|
||||
|
||||
test("owner-filtered CRM overview counts only contacts linked to that owner's customers", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const queries: Array<{ resource: string; options: Record<string, unknown> }> = [];
|
||||
const fake = {
|
||||
listRecords: async (resource: string) => {
|
||||
if (resource === config.user.resource) {
|
||||
return { data: [{ id: 10, username: "alice", nickname: "Alice" }] };
|
||||
}
|
||||
return { data: [], meta: { count: 0 } };
|
||||
},
|
||||
queryRecords: async (resource: string, options: Record<string, unknown>) => {
|
||||
queries.push({ resource, options });
|
||||
return Array.isArray(options.dimensions) ? { data: [] } : { data: [{ count: 1 }] };
|
||||
},
|
||||
} as unknown as NocoBaseClient;
|
||||
|
||||
const result = await new CrmService(config, fake).crmOverview({ owner: "alice" });
|
||||
assert.deepEqual(result.totals, { customers: 1, contacts: 1, followups: 1 });
|
||||
const contactCount = queries.find(
|
||||
({ resource, options }) => resource === config.contact.resource && Array.isArray(options.measures),
|
||||
);
|
||||
assert.deepEqual(contactCount?.options.filter, {
|
||||
$and: [
|
||||
{
|
||||
[config.contact.customerField]: {
|
||||
[config.customer.ownerField]: { id: { $eq: 10 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("health never returns the Authorize Key", async () => {
|
||||
const fake = {
|
||||
health: async () => ({ ok: true, endpoint: "https://nb.example", status: "authenticated" }),
|
||||
@@ -111,6 +143,154 @@ test("health never returns the Authorize Key", async () => {
|
||||
assert.doesNotMatch(JSON.stringify(result), /token|secret|authorizeKey/i);
|
||||
});
|
||||
|
||||
test("getProject returns contacts only at the top level when requested", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const fake = {
|
||||
getRecord: async (_resource: string, _projectId: unknown, options: Record<string, unknown>) => {
|
||||
requests.push(options);
|
||||
return {
|
||||
data: {
|
||||
[config.project.idField]: 30,
|
||||
[config.project.nameField]: "Project",
|
||||
[config.project.contactField]: [{ [config.contact.idField]: 2, name: "Contact" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as NocoBaseClient;
|
||||
const service = new CrmService(config, fake);
|
||||
|
||||
const withoutContacts = await service.getProject(30, {
|
||||
includeContacts: false,
|
||||
includeFollowups: false,
|
||||
includeUpdates: false,
|
||||
});
|
||||
assert.equal("contacts" in withoutContacts, false);
|
||||
assert.equal("contacts" in (withoutContacts.project as Record<string, unknown>), false);
|
||||
assert.equal(
|
||||
((requests[0]?.fields ?? []) as string[]).some(
|
||||
(field) => field === config.project.contactField || field.startsWith(`${config.project.contactField}.`),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const withContacts = await service.getProject(30, {
|
||||
includeContacts: true,
|
||||
includeFollowups: false,
|
||||
includeUpdates: false,
|
||||
});
|
||||
assert.deepEqual(withContacts.contacts, [{ id: 2, name: "Contact" }]);
|
||||
assert.equal("contacts" in (withContacts.project as Record<string, unknown>), false);
|
||||
assert.equal(
|
||||
((requests[1]?.fields ?? []) as string[]).some((field) =>
|
||||
field.startsWith(`${config.project.contactField}.`),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("listStaleCustomers uses paginated max aggregation and returns an exact total", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const customers = Array.from({ length: 101 }, (_, index) => ({
|
||||
[config.customer.idField]: index + 1,
|
||||
name: `Customer ${index + 1}`,
|
||||
}));
|
||||
const latest = customers.map((customer) => ({
|
||||
customer_id: customer[config.customer.idField],
|
||||
last_followup_at: "2026-01-01T00:00:00Z",
|
||||
}));
|
||||
const queryRequests: Array<Record<string, unknown>> = [];
|
||||
const fake = {
|
||||
listRecords: async (resource: string, options: Record<string, unknown>) => {
|
||||
assert.equal(resource, config.customer.resource);
|
||||
const pageSize = options.pageSize as number;
|
||||
const start = ((options.page as number) - 1) * pageSize;
|
||||
return { data: customers.slice(start, start + pageSize), meta: { count: customers.length } };
|
||||
},
|
||||
queryRecords: async (resource: string, options: Record<string, unknown>) => {
|
||||
assert.equal(resource, config.followup.resource);
|
||||
queryRequests.push(options);
|
||||
const offset = options.offset as number;
|
||||
const limit = options.limit as number;
|
||||
return { data: latest.slice(offset, offset + limit) };
|
||||
},
|
||||
} as unknown as NocoBaseClient;
|
||||
|
||||
const result = await new CrmService(config, fake).listStaleCustomers({
|
||||
asOf: "2026-08-20",
|
||||
inactiveDays: 30,
|
||||
limit: 20,
|
||||
});
|
||||
assert.equal(result.total, 101);
|
||||
assert.equal(result.total_is_exact, true);
|
||||
assert.deepEqual(
|
||||
queryRequests.map((request) => request.offset),
|
||||
[0, 100],
|
||||
);
|
||||
assert.deepEqual(queryRequests[0]?.dimensions, [
|
||||
{
|
||||
field: [config.followup.customerField, config.customer.idField],
|
||||
alias: "customer_id",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(queryRequests[0]?.measures, [
|
||||
{ field: [config.followup.dateField], aggregation: "max", alias: "last_followup_at" },
|
||||
]);
|
||||
assert.equal(queryRequests[0]?.timezone, config.timezone);
|
||||
});
|
||||
|
||||
test("listStaleCustomers never presents a truncated scan count as an exact total", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const fake = {
|
||||
listRecords: async () => ({
|
||||
data: [{ [config.customer.idField]: 1, name: "Customer" }],
|
||||
meta: { count: 2 },
|
||||
}),
|
||||
queryRecords: async () => ({ data: [] }),
|
||||
} as unknown as NocoBaseClient;
|
||||
|
||||
const result = await new CrmService(config, fake).listStaleCustomers({
|
||||
asOf: "2026-08-20",
|
||||
inactiveDays: 30,
|
||||
});
|
||||
assert.equal("total" in result, false);
|
||||
assert.equal(result.total_is_exact, false);
|
||||
assert.equal(result.scanned_stale_count, 1);
|
||||
assert.equal(result.pagination_scope, "scanned_records_only");
|
||||
assert.deepEqual(result.data_quality_warnings, ["customer scan was truncated"]);
|
||||
});
|
||||
|
||||
test("listStaleCustomers marks an overflowing follow-up aggregation as inexact", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const latest = Array.from({ length: 5_001 }, (_, index) => ({
|
||||
customer_id: index + 1,
|
||||
last_followup_at: "2026-01-01T00:00:00Z",
|
||||
}));
|
||||
const fake = {
|
||||
listRecords: async () => ({
|
||||
data: [{ [config.customer.idField]: 1, name: "Customer" }],
|
||||
meta: { count: 1 },
|
||||
}),
|
||||
queryRecords: async (_resource: string, options: Record<string, unknown>) => {
|
||||
const offset = options.offset as number;
|
||||
const limit = options.limit as number;
|
||||
return { data: latest.slice(offset, offset + limit) };
|
||||
},
|
||||
} as unknown as NocoBaseClient;
|
||||
|
||||
const result = await new CrmService(config, fake).listStaleCustomers({
|
||||
asOf: "2026-08-20",
|
||||
inactiveDays: 30,
|
||||
});
|
||||
assert.equal("total" in result, false);
|
||||
assert.equal(result.total_is_exact, false);
|
||||
assert.equal(result.scanned_stale_count, 1);
|
||||
assert.deepEqual(result.data_quality_warnings, ["follow-up customer aggregation was truncated"]);
|
||||
assert.deepEqual(result.truncation, {
|
||||
followups: { minimum_total: 5_001, returned: 5_000, limit: 5_000, next_offset: 5_000 },
|
||||
});
|
||||
});
|
||||
|
||||
test("all CRM service surfaces execute against the direct HTTP adapter contract", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const rows: Record<string, Record<string, unknown>> = {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
type AuthorizationPrompt,
|
||||
beginBrowserLogin,
|
||||
loginWithBrowser,
|
||||
type PendingOAuthAuthorization,
|
||||
pollBrowserLogin,
|
||||
refreshOAuthCredentials,
|
||||
validatePendingOAuthAuthorization,
|
||||
} from "../src/oauth.js";
|
||||
|
||||
const discovery = {
|
||||
issuer: "https://justconn.top/api",
|
||||
device_authorization_endpoint: "https://justconn.top/api/idpOAuth/device/auth",
|
||||
registration_endpoint: "https://justconn.top/api/idpOAuth/register",
|
||||
token_endpoint: "https://justconn.top/api/idpOAuth/token",
|
||||
grant_types_supported: [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
],
|
||||
scopes_supported: ["openid", "offline_access", "api", "mcp"],
|
||||
token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
|
||||
};
|
||||
|
||||
test("uses NocoBase dynamic registration and browser device authorization without exposing tokens", async () => {
|
||||
const calls: Array<{ body: string; method: string; url: string }> = [];
|
||||
let tokenPolls = 0;
|
||||
const fetchMock = (async (input: string | URL | Request, init: RequestInit = {}) => {
|
||||
const url = String(input);
|
||||
const method = init.method ?? "GET";
|
||||
const body =
|
||||
typeof init.body === "string" || init.body instanceof URLSearchParams ? String(init.body) : "";
|
||||
calls.push({ body, method, url });
|
||||
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
|
||||
if (url.endsWith("/idpOAuth/register")) {
|
||||
return jsonResponse(
|
||||
{
|
||||
client_id: "desktop-client",
|
||||
registration_access_token: "must-not-be-stored",
|
||||
token_endpoint_auth_method: "none",
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/idpOAuth/device/auth")) {
|
||||
return jsonResponse({
|
||||
device_code: "private-device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://justconn.top/idpOAuth/device",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/idpOAuth/token")) {
|
||||
tokenPolls += 1;
|
||||
if (tokenPolls === 1) return jsonResponse({ error: "authorization_pending" }, 400);
|
||||
return jsonResponse({
|
||||
access_token: "oauth-access-token",
|
||||
refresh_token: "oauth-refresh-token",
|
||||
token_type: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expires_in: 900,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method} ${url}`);
|
||||
}) as typeof fetch;
|
||||
let prompt: AuthorizationPrompt | undefined;
|
||||
const sleeps: number[] = [];
|
||||
const credentials = await loginWithBrowser({
|
||||
fetch: fetchMock,
|
||||
now: () => 1_000,
|
||||
openBrowser: async () => true,
|
||||
onAuthorization: (value) => {
|
||||
prompt = value;
|
||||
},
|
||||
sleep: async (milliseconds) => {
|
||||
sleeps.push(milliseconds);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(credentials.clientId, "desktop-client");
|
||||
assert.equal(credentials.accessToken, "oauth-access-token");
|
||||
assert.equal(credentials.refreshToken, "oauth-refresh-token");
|
||||
assert.equal(credentials.expiresAt, 901_000);
|
||||
assert.equal(calls.filter((call) => call.url.endsWith("/.well-known/openid-configuration")).length, 1);
|
||||
assert.doesNotMatch(JSON.stringify(credentials), /must-not-be-stored/);
|
||||
assert.deepEqual(prompt, {
|
||||
opened: true,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
userCode: "ABCD-EFGH",
|
||||
expiresIn: 600,
|
||||
});
|
||||
assert.deepEqual(sleeps, [5_000, 5_000]);
|
||||
|
||||
const registration = JSON.parse(calls.find((call) => call.url.endsWith("/register"))?.body ?? "{}");
|
||||
assert.equal(registration.application_type, "native");
|
||||
assert.equal(registration.token_endpoint_auth_method, "none");
|
||||
assert.deepEqual(registration.response_types, []);
|
||||
assert.deepEqual(registration.redirect_uris, []);
|
||||
assert.equal(registration.client_secret, undefined);
|
||||
|
||||
const deviceForm = new URLSearchParams(calls.find((call) => call.url.endsWith("/device/auth"))?.body);
|
||||
assert.equal(deviceForm.get("scope"), "api offline_access");
|
||||
assert.equal(deviceForm.get("resource"), "https://justconn.top/api/");
|
||||
});
|
||||
|
||||
test("resumes one persisted browser authorization without creating another link", async () => {
|
||||
let currentTime = 1_000;
|
||||
let deviceStarts = 0;
|
||||
let tokenPolls = 0;
|
||||
const fetchMock = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
|
||||
if (url.endsWith("/idpOAuth/device/auth")) {
|
||||
deviceStarts += 1;
|
||||
return jsonResponse({
|
||||
device_code: "private-resumable-device-code",
|
||||
user_code: "RESUME-CODE",
|
||||
verification_uri: "https://justconn.top/idpOAuth/device",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/idpOAuth/token")) {
|
||||
tokenPolls += 1;
|
||||
return jsonResponse({
|
||||
access_token: "resumed-access-token",
|
||||
refresh_token: "resumed-refresh-token",
|
||||
token_type: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expires_in: 900,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const authorization = await beginBrowserLogin({
|
||||
clientId: "desktop-client",
|
||||
fetch: fetchMock,
|
||||
now: () => currentTime,
|
||||
openBrowser: async () => false,
|
||||
});
|
||||
assert.equal(deviceStarts, 1);
|
||||
assert.equal(authorization.deviceCode, "private-resumable-device-code");
|
||||
assert.equal(authorization.nextPollAt, 6_000);
|
||||
|
||||
currentTime = 5_000;
|
||||
const tooEarly = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime });
|
||||
assert.equal(tooEarly.status, "authorization_pending");
|
||||
assert.equal(tokenPolls, 0);
|
||||
|
||||
currentTime = 6_000;
|
||||
const resumed = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime });
|
||||
assert.equal(resumed.status, "authenticated");
|
||||
if (resumed.status === "authenticated") {
|
||||
assert.equal(resumed.credentials.accessToken, "resumed-access-token");
|
||||
assert.equal(resumed.credentials.authorizationSessionId, authorization.sessionId);
|
||||
}
|
||||
assert.equal(tokenPolls, 1);
|
||||
assert.equal(deviceStarts, 1);
|
||||
});
|
||||
|
||||
test("normalizes pending and slow-down responses for resumable browser login", async () => {
|
||||
for (const scenario of [
|
||||
{ code: "authorization_pending", intervalSeconds: 5, nextPollAt: 11_000 },
|
||||
{ code: "slow_down", intervalSeconds: 10, nextPollAt: 16_000 },
|
||||
]) {
|
||||
let discoveries = 0;
|
||||
const fetchMock = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) {
|
||||
discoveries += 1;
|
||||
return jsonResponse(discovery);
|
||||
}
|
||||
if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400);
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await pollBrowserLogin(persistedAuthorization(), {
|
||||
fetch: fetchMock,
|
||||
now: () => 6_000,
|
||||
});
|
||||
assert.equal(result.status, "authorization_pending");
|
||||
if (result.status === "authorization_pending") {
|
||||
assert.equal(result.authorization.intervalSeconds, scenario.intervalSeconds);
|
||||
assert.equal(result.authorization.nextPollAt, scenario.nextPollAt);
|
||||
}
|
||||
assert.equal(discoveries, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("maps cancelled and expired resumable browser login to stable errors", async () => {
|
||||
for (const scenario of [
|
||||
{ code: "access_denied", message: /cancelled/ },
|
||||
{ code: "expired_token", message: /expired/ },
|
||||
]) {
|
||||
const fetchMock = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
|
||||
if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400);
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
await assert.rejects(
|
||||
pollBrowserLogin(persistedAuthorization(), { fetch: fetchMock, now: () => 6_000 }),
|
||||
scenario.message,
|
||||
);
|
||||
}
|
||||
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
pollBrowserLogin(persistedAuthorization({ expiresAt: 6_000 }), {
|
||||
fetch: (async () => {
|
||||
calls += 1;
|
||||
throw new Error("must not fetch");
|
||||
}) as typeof fetch,
|
||||
now: () => 6_000,
|
||||
}),
|
||||
/expired/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("rejects a persisted browser authorization that was redirected off origin", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validatePendingOAuthAuthorization({
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: "session",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "client",
|
||||
deviceCode: "device-code",
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://evil.example/device",
|
||||
userCode: "CODE",
|
||||
expiresIn: 600,
|
||||
},
|
||||
expiresAt: 601_000,
|
||||
intervalSeconds: 5,
|
||||
nextPollAt: 6_000,
|
||||
}),
|
||||
/untrusted browser URL/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects OAuth discovery that points tokens at another origin", async () => {
|
||||
const fetchMock = (async () =>
|
||||
jsonResponse({ ...discovery, token_endpoint: "https://evil.example/token" })) as typeof fetch;
|
||||
await assert.rejects(
|
||||
loginWithBrowser({ fetch: fetchMock, openBrowser: async () => true }),
|
||||
/untrusted endpoint/,
|
||||
);
|
||||
});
|
||||
|
||||
test("refreshes a public-client OAuth session and keeps a rotated refresh token optional", async () => {
|
||||
const bodies: string[] = [];
|
||||
const fetchMock = (async (input: string | URL | Request, init: RequestInit = {}) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
|
||||
bodies.push(String(init.body));
|
||||
return jsonResponse({
|
||||
access_token: "new-access",
|
||||
token_type: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expires_in: 600,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
const refreshed = await refreshOAuthCredentials(
|
||||
{
|
||||
type: "oauth",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
tokenType: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expiresAt: 10,
|
||||
},
|
||||
{ fetch: fetchMock, now: () => 2_000 },
|
||||
);
|
||||
assert.equal(refreshed.accessToken, "new-access");
|
||||
assert.equal(refreshed.refreshToken, "old-refresh");
|
||||
assert.equal(refreshed.expiresAt, 602_000);
|
||||
const form = new URLSearchParams(bodies[0]);
|
||||
assert.equal(form.get("grant_type"), "refresh_token");
|
||||
assert.equal(form.get("client_id"), "desktop-client");
|
||||
assert.equal(form.get("refresh_token"), "old-refresh");
|
||||
});
|
||||
|
||||
function jsonResponse(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function persistedAuthorization(
|
||||
overrides: Partial<PendingOAuthAuthorization> = {},
|
||||
): PendingOAuthAuthorization {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: "persisted-session",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
deviceCode: "private-device-code",
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE",
|
||||
userCode: "RESUME-CODE",
|
||||
expiresIn: 600,
|
||||
},
|
||||
expiresAt: 601_000,
|
||||
intervalSeconds: 5,
|
||||
nextPollAt: 6_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
+103
-3
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import type { AuthService } from "../src/auth-service.js";
|
||||
import type { CrmService } from "../src/crm-service.js";
|
||||
import { NocoBaseError } from "../src/nocobase-client.js";
|
||||
import type { ProjectService } from "../src/project-service.js";
|
||||
import { createServer } from "../src/tools.js";
|
||||
|
||||
@@ -17,12 +19,37 @@ function fakeService(): CrmService {
|
||||
) as CrmService;
|
||||
}
|
||||
|
||||
async function connected(readonly: boolean): Promise<{
|
||||
function fakeAuthService(): AuthService {
|
||||
return {
|
||||
start: async () => ({
|
||||
status: "authorization_pending",
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
user_code: "TEST-CODE",
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
}),
|
||||
status: async () => ({ status: "login_required" }),
|
||||
};
|
||||
}
|
||||
|
||||
async function connected(
|
||||
readonly: boolean,
|
||||
crmService: CrmService = fakeService(),
|
||||
): Promise<{
|
||||
client: Client;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const server = createServer({
|
||||
crmService: fakeService(),
|
||||
authService: fakeAuthService(),
|
||||
crmService,
|
||||
projectService: fakeService() as unknown as ProjectService,
|
||||
readonly,
|
||||
});
|
||||
@@ -35,13 +62,15 @@ async function connected(readonly: boolean): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
test("read-only server exposes the stable 17-tool contract", async () => {
|
||||
test("read-only server exposes CRM tools plus browser login tools", async () => {
|
||||
const connection = await connected(true);
|
||||
try {
|
||||
const result = await connection.client.listTools();
|
||||
assert.deepEqual(
|
||||
new Set(result.tools.map((tool) => tool.name)),
|
||||
new Set([
|
||||
"nocobase_login",
|
||||
"nocobase_login_status",
|
||||
"crm_health",
|
||||
"crm_schema",
|
||||
"crm_overview",
|
||||
@@ -66,6 +95,77 @@ test("read-only server exposes the stable 17-tool contract", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("login tools require a private user handoff instead of headless remote desktop", async () => {
|
||||
const connection = await connected(true);
|
||||
try {
|
||||
const tools = (await connection.client.listTools()).tools;
|
||||
const login = tools.find((tool) => tool.name === "nocobase_login");
|
||||
const status = tools.find((tool) => tool.name === "nocobase_login_status");
|
||||
assert.ok(login?.description);
|
||||
assert.ok(status?.description);
|
||||
assert.match(login.description, /MUST deliver login_url and user_code/);
|
||||
assert.match(login.description, /current private conversation/);
|
||||
assert.match(login.description, /headless server/);
|
||||
assert.match(login.description, /remote desktop is not required/);
|
||||
assert.match(login.description, /Never post them publicly/);
|
||||
assert.match(status.description, /private-delivery policy/);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("authentication failures tell the Agent to use the MCP browser login flow", async () => {
|
||||
const service = {
|
||||
crmHealth: async () => {
|
||||
throw new NocoBaseError(401, "NocoBase credentials are unavailable", {
|
||||
authenticationFailed: true,
|
||||
});
|
||||
},
|
||||
} as unknown as CrmService;
|
||||
const connection = await connected(true, service);
|
||||
try {
|
||||
const result = await connection.client.callTool({ name: "crm_health", arguments: {} });
|
||||
const structured = result.structuredContent as { error: Record<string, unknown> };
|
||||
assert.equal(structured.error.code, "nocobase_auth_failed");
|
||||
assert.match(String(structured.error.hint), /nocobase_login/);
|
||||
assert.match(String(structured.error.hint), /nocobase_login_status/);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("browser login tool returns only the safe user-facing authorization fields", async () => {
|
||||
const connection = await connected(true);
|
||||
try {
|
||||
const result = await connection.client.callTool({
|
||||
name: "nocobase_login",
|
||||
arguments: {},
|
||||
});
|
||||
assert.deepEqual(result.structuredContent, {
|
||||
status: "authorization_pending",
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
user_code: "TEST-CODE",
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
});
|
||||
const payload = result.structuredContent as Record<string, unknown>;
|
||||
assert.equal(Object.hasOwn(payload, "access_token"), false);
|
||||
assert.equal(Object.hasOwn(payload, "refresh_token"), false);
|
||||
assert.equal(Object.hasOwn(payload, "device_code"), false);
|
||||
assert.equal(Object.hasOwn(payload, "password"), false);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("write mode adds only fixed project create and update tools", async () => {
|
||||
const connection = await connected(false);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user