feat: add NocoBase browser OAuth login
This commit is contained in:
+36
-20
@@ -1,23 +1,23 @@
|
|||||||
# 安装 NocoBase CRM MCP
|
# 安装 NocoBase CRM MCP
|
||||||
|
|
||||||
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在隐藏提示中输入自己的 NocoBase Authorize Key。
|
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在浏览器中登录自己的 NocoBase 账号完成授权。
|
||||||
|
|
||||||
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。
|
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更或等待用户在 NocoBase 网页登录授权时才中断用户。
|
||||||
|
|
||||||
## 完成标准
|
## 完成标准
|
||||||
|
|
||||||
- 下载本文件所在的同一固定 Release,不使用默认分支;
|
- 下载本文件所在的同一固定 Release,不使用默认分支;
|
||||||
- 只连接固定的 NocoBase API `https://justconn.top/api`;
|
- 只连接固定的 NocoBase API `https://justconn.top/api`;
|
||||||
- MCP 在用户本机以 stdio 运行,默认只读;
|
- MCP 在用户本机以 stdio 运行,默认只读;
|
||||||
- 认证属于当前用户自己的个人 Authorize Key 和业务角色;
|
- 通过 NocoBase 原生 OAuth 2.1 认证当前用户,权限仍跟随本人的业务角色;
|
||||||
- 当前 MCP 客户端已注册 `nocobase-crm`;
|
- 当前 MCP 客户端已注册 `nocobase-crm`;
|
||||||
- `crm_health` 返回 `ok=true`。
|
- `crm_health` 返回 `ok=true`。
|
||||||
|
|
||||||
## 安全规则
|
## 安全规则
|
||||||
|
|
||||||
- 不索取账号密码,不使用共享服务账号或管理员 Key。
|
- 不索取账号密码、OAuth Token 或 Authorize Key,不使用共享服务账号或管理员身份。
|
||||||
- 不让用户把 Key 发到聊天、命令参数、环境变量、剪贴板日志或 MCP 配置。
|
- 用户只能在 `https://justconn.top` 的 NocoBase 页面输入账号信息并确认授权。
|
||||||
- Key 只能由用户输入到安装程序的本机隐藏 TTY 提示。
|
- OAuth Token 不得进入聊天、命令参数、环境变量、CRM 配置、剪贴板日志或 MCP 客户端配置。
|
||||||
- 不使用 `sudo`,不执行 `curl | sh` 或 `wget | sh`。
|
- 不使用 `sudo`,不执行 `curl | sh` 或 `wget | sh`。
|
||||||
- 不静默安装 Git、Node.js、包管理器或其他系统工具。
|
- 不静默安装 Git、Node.js、包管理器或其他系统工具。
|
||||||
- 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。
|
- 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
- `donaldttt-nocobase-crm-mcp-<version>.tgz`;
|
- `donaldttt-nocobase-crm-mcp-<version>.tgz`;
|
||||||
- `SHA256SUMS`。
|
- `SHA256SUMS`。
|
||||||
|
|
||||||
仓库为 Private 时,必须使用当前用户已经授权的 GitHub 连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户 GitHub Token 到聊天。
|
仓库访问受限时,必须使用当前用户已经授权的代码托管平台连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户的访问令牌到聊天。
|
||||||
|
|
||||||
### 2. 一次性说明本机变更
|
### 2. 一次性说明本机变更
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
正常安装只会留下:
|
正常安装只会留下:
|
||||||
|
|
||||||
- 应用数据目录中的 npm 包;
|
- 应用数据目录中的 npm 包;
|
||||||
- 不含 Key 的 CRM `config.json`;
|
- 不含凭据的 CRM `config.json`;
|
||||||
- 权限受限的本机凭证文件;
|
- 权限受限的本机凭证文件;
|
||||||
- MCP 客户端配置及其备份。
|
- MCP 客户端配置及其备份。
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
### 3. 下载并验证固定发布物
|
### 3. 下载并验证固定发布物
|
||||||
|
|
||||||
通过已授权的 GitHub API/连接下载两个 Release 资产到新建的临时目录。验证 tarball 的 SHA-256 与 `SHA256SUMS` 完全一致;不一致立即停止并删除未使用的下载文件。
|
通过当前 Gitea 实例或经批准分发渠道的已授权 API/连接,将两个 Release 资产下载到新建的临时目录。验证 tarball 的 SHA-256 与 `SHA256SUMS` 完全一致;不一致立即停止并删除未使用的下载文件。
|
||||||
|
|
||||||
选择用户级安装前缀,例如:
|
选择用户级安装前缀,例如:
|
||||||
|
|
||||||
@@ -70,19 +70,33 @@ npm install --prefix <install-prefix> --omit=dev <verified-release.tgz>
|
|||||||
|
|
||||||
定位安装后的命令:POSIX 为 `<install-prefix>/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。
|
定位安装后的命令:POSIX 为 `<install-prefix>/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。
|
||||||
|
|
||||||
### 4. 个人 Key 设置
|
### 4. 浏览器登录 NocoBase
|
||||||
|
|
||||||
先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的个人 Authorize Key。不要让用户把 Key 发给 Agent。
|
运行:
|
||||||
|
|
||||||
在用户可直接控制的本机终端运行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
<installed-command> setup --json
|
<installed-command> setup --json
|
||||||
```
|
```
|
||||||
|
|
||||||
安装程序会先验证安全 TTY,再显示固定 API 地址和隐藏 Key 提示;没有安全 TTY 时会在写文件前停止。Key 通过 `https://justconn.top/api/roles:check` 验证成功后才保存,并立即执行 `crm_health`。凭证文件只保存 Key,不保存或接受 API 地址。Key 不会出现在返回 JSON、命令参数、MCP 配置或日志中。
|
安装程序通过 NocoBase 的 OAuth 发现接口动态注册本机公共客户端,请求 `api offline_access` 权限,并自动打开带确认码的 NocoBase 设备授权页。用户在网页中登录、核对确认码并点击授权;程序随后取得短期访问令牌和可撤销的刷新令牌,通过 `https://justconn.top/api/roles:check` 验证成功后保存,并立即执行 `crm_health`。
|
||||||
|
|
||||||
POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。不要复制其他用户的凭证文件;每个操作系统用户都必须单独设置自己的 Key。
|
浏览器没有自动打开时,安装程序会继续显示完整授权链接和确认码。可重新运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<installed-command> setup --no-browser --json
|
||||||
|
```
|
||||||
|
|
||||||
|
然后由用户手动打开链接。设备授权约十分钟后失效;密码和浏览器会话始终留在 NocoBase,安装程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期,不影响日常 MCP 调用。
|
||||||
|
|
||||||
|
POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 不会出现在返回 JSON、命令参数、MCP 配置或日志中。不要复制其他用户的凭证文件;每个操作系统用户都必须单独登录。
|
||||||
|
|
||||||
|
只有在 OAuth 暂时不可用、管理员明确要求且用户拥有个人 Authorize Key 时,才使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<installed-command> setup --manual-key --json
|
||||||
|
```
|
||||||
|
|
||||||
|
该备用模式必须在用户可直接控制的安全 TTY 中隐藏输入 Key;仍不得让用户把 Key 发给 Agent。
|
||||||
|
|
||||||
### 5. 注册并验证 MCP
|
### 5. 注册并验证 MCP
|
||||||
|
|
||||||
@@ -94,7 +108,7 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当
|
|||||||
4. 重载或重启 MCP 客户端;
|
4. 重载或重启 MCP 客户端;
|
||||||
5. 调用 `crm_health`,要求 `ok=true`。
|
5. 调用 `crm_health`,要求 `ok=true`。
|
||||||
|
|
||||||
最终只报告 Release、安装目录、配置备份位置和只读/写入模式,不显示任何 Key。
|
最终只报告 Release、安装目录、配置备份位置、认证方式和只读/写入模式,不显示任何 Token 或 Key。
|
||||||
|
|
||||||
## 可选写入
|
## 可选写入
|
||||||
|
|
||||||
@@ -102,14 +116,16 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当
|
|||||||
|
|
||||||
## 故障处理
|
## 故障处理
|
||||||
|
|
||||||
- `nocobase_auth_failed`:让用户撤销失效 Key,重新运行同一个 setup 命令并在隐藏提示输入新 Key。
|
- 浏览器未打开:使用 `setup --no-browser`,让用户手动打开终端显示的 `justconn.top` 授权链接。
|
||||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限 Key。
|
- 授权超时或被取消:重新运行 `setup`,再次核对确认码并授权。
|
||||||
|
- `nocobase_auth_failed`:重新运行 `setup` 完成浏览器登录;不要换用共享高权限 Token 或 Key。
|
||||||
|
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限身份。
|
||||||
- Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。
|
- Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。
|
||||||
- Private Release 无权访问:报告分发权限问题,不退回 clone 默认分支。
|
- 受限 Release 无权访问:报告 Gitea 或分发渠道的权限问题,不退回 clone 默认分支。
|
||||||
|
|
||||||
## 用户可发送给 Agent 的最短指令
|
## 用户可发送给 Agent 的最短指令
|
||||||
|
|
||||||
```text
|
```text
|
||||||
请按这个固定 Release 的协议帮我完成本机安装。默认只读,使用我自己的 NocoBase Authorize Key;只让我在本机隐藏提示中输入 Key,不要静默安装系统工具。完成前必须验证 crm_health:
|
请按这个固定 Release 的协议帮我完成本机安装。默认只读;请运行 setup,让我只在 NocoBase 官方网页登录并确认授权。不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前必须验证 crm_health:
|
||||||
<固定 Release 的 INSTALL.md 链接>
|
<固定 Release 的 INSTALL.md 链接>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,34 +1,95 @@
|
|||||||
# NocoBase CRM MCP
|
# NocoBase CRM MCP
|
||||||
|
|
||||||
本项目是在用户电脑本机运行的 NocoBase CRM MCP。每位用户使用自己的个人 Authorize Key,所有查询和写入继续由 NocoBase ACL、字段权限和行级权限裁决;不需要共享账号或远程 MCP 服务。
|
在你的电脑上,让 AI 客户端安全地查询 NocoBase CRM。安装时通过浏览器登录 NocoBase;每位用户使用自己的身份,看到的数据范围与本人在 NocoBase 中的权限一致。
|
||||||
|
|
||||||
## 用户侧依赖
|
|
||||||
|
|
||||||
正式 Release 是一个已打包的 npm tarball,内部包含 MCP SDK 和运行代码。用户电脑只需要:
|
|
||||||
|
|
||||||
- Node.js 20.11 或更高版本(npm 随 Node.js 一起提供);
|
|
||||||
- 能访问固定的 NocoBase 环境 `https://justconn.top`;
|
|
||||||
- 用户自己的个人 Authorize Key。
|
|
||||||
|
|
||||||
用户不再需要 Git、Python、uv、Python 包或 `nb CLI`。安装也不会全局安装本项目,而是放到用户自己的应用数据目录。
|
|
||||||
|
|
||||||
Private GitHub 仓库的 Release 只能由已经获得仓库或分发渠道权限的用户下载。若要实现面向任意用户的“一条链接安装”,需要另行提供用户 Agent 能认证访问的下载地址。
|
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
把对应 Release Tag 下的不可变 [`INSTALL.md`](INSTALL.md) 链接交给安装 Agent。该协议不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定客户端。Agent 会:
|
推荐把安装交给支持终端操作的 AI 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`,不提供删除或任意资源工具。
|
- Node.js 20.11 或更高版本;
|
||||||
|
- 能访问 `https://justconn.top`;
|
||||||
|
- 你自己的 NocoBase 账号;
|
||||||
|
- 能读取当前 Gitea 仓库对应 Release 的权限。
|
||||||
|
|
||||||
|
如果 Node.js 尚未安装,安装 Agent 会先说明需要进行的系统变更,并在获得你确认后再继续。
|
||||||
|
|
||||||
|
不需要提前创建或复制 Authorize Key。安装程序会打开 NocoBase 官方网页;你在网页中登录并确认授权后,本机安装会自动继续。
|
||||||
|
|
||||||
|
### 最快安装方式
|
||||||
|
|
||||||
|
1. 打开当前 Gitea 仓库的 **Releases(版本发布)** 页面,选择要安装的固定版本。
|
||||||
|
2. 确认该版本包含 npm 安装包和 `SHA256SUMS`,然后打开同一版本下的 `INSTALL.md`。不要使用默认分支中的安装说明代替固定版本。
|
||||||
|
3. 把这个固定版本的 `INSTALL.md` 链接和下面的文字一起发给安装 Agent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请按这个固定版本的安装协议帮我完成 NocoBase CRM MCP 本机安装。
|
||||||
|
默认只读;请运行 setup,让我只在 NocoBase 官方网页完成登录和授权。
|
||||||
|
不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前请验证 crm_health 返回 ok=true。
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 浏览器打开后,在 `justconn.top` 登录并核对页面与终端显示的确认码,然后点击授权。浏览器没有自动打开时,复制终端显示的授权链接手动打开。
|
||||||
|
5. 安装完成后,确认 Agent 报告 `crm_health` 返回 `ok=true`。
|
||||||
|
|
||||||
|
完整的安装和验收规则见 [INSTALL.md](INSTALL.md)。实际安装时应使用所选 Release Tag 中的同一文件。
|
||||||
|
|
||||||
|
## 重要注意事项
|
||||||
|
|
||||||
|
- **只在 NocoBase 网页登录:** 安装程序不会读取浏览器 Cookie,也不会接触你的密码;OAuth 令牌只保存到权限受限的个人凭据文件,不会进入聊天、命令参数、环境变量、CRM 配置、MCP 客户端配置或日志。
|
||||||
|
- **默认只读:** 安装后默认只能查询 CRM,不会创建、更新或删除业务数据。
|
||||||
|
- **权限跟随本人:** MCP 不会绕过 NocoBase 的角色、字段权限和行级权限,也不会使用共享管理员账号。
|
||||||
|
- **只连接公司环境:** 程序固定连接 `https://justconn.top/api`,不会要求你填写其他 NocoBase 地址。
|
||||||
|
- **安装在用户目录:** 不使用 `sudo`,不会全局安装本项目,也不需要 Git、Python、uv 或 `nb CLI`。
|
||||||
|
- **受限仓库需要授权:** 如果 Agent 无法读取 Gitea Release,请联系仓库管理员开通权限,不要把访问令牌发到聊天中。
|
||||||
|
|
||||||
|
## 安装后可以做什么
|
||||||
|
|
||||||
|
你可以直接让 AI:
|
||||||
|
|
||||||
|
- 查询客户、联系人和跟进记录;
|
||||||
|
- 按负责人、团队、状态或时间范围筛选 CRM 数据;
|
||||||
|
- 查看 CRM 总览、团队贡献和长期未跟进客户;
|
||||||
|
- 查看项目详情和项目跟进时间线。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
列出我负责的、最近 30 天没有有效跟进的客户。
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
查看“华东团队”本月的客户、跟进和在途项目概况。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 可选写入
|
||||||
|
|
||||||
|
只有在你明确需要创建或更新项目,并且管理员已经确认你的 NocoBase 权限时,才应启用写入模式。
|
||||||
|
|
||||||
|
写入模式只增加固定字段的项目创建和更新能力,不提供删除操作,也不开放任意集合或任意字段写入。不要为了绕过权限错误而换用共享高权限 Key。
|
||||||
|
|
||||||
|
## 遇到问题
|
||||||
|
|
||||||
|
- 登录失效:重新运行安装程序的 `setup`,在浏览器中再次授权;正常情况下访问令牌会在后台自动刷新。
|
||||||
|
- 浏览器没有打开:重新运行 `setup --no-browser`,然后手动打开终端显示的授权链接。
|
||||||
|
- 仅在 OAuth 暂时不可用且管理员明确要求时,才使用 `setup --manual-key` 的隐藏输入备用模式;不要把 Key 发到聊天中。
|
||||||
|
- 返回 `NO_PERMISSIONS`:请检查本人 NocoBase 角色和数据权限。
|
||||||
|
- 请求超时:确认可以访问 `https://justconn.top`,或缩小查询范围。
|
||||||
|
- Release 无法下载:确认当前账号或 Agent 已获得 Gitea 仓库和 Release 的读取权限。
|
||||||
|
|
||||||
|
更多故障处理和更新、回滚说明见 [本机交付与权限验收](docs/LOCAL_DELIVERY.md)。
|
||||||
|
|
||||||
|
## 更多文档
|
||||||
|
|
||||||
|
- [完整安装协议](INSTALL.md):供安装 Agent 执行的固定版本安装与验收流程。
|
||||||
|
- [命令与工具参考](docs/REFERENCE.md):CLI 命令、MCP 工具和可选运行开关。
|
||||||
|
- [内部架构](docs/ARCHITECTURE.md):模块边界、身份与网络安全设计。
|
||||||
|
- [本机交付与权限验收](docs/LOCAL_DELIVERY.md):运维、权限验收、更新和回滚。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
|
开发者可从源码运行以下检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
npm run check
|
npm run check
|
||||||
@@ -37,40 +98,4 @@ npm run build
|
|||||||
node dist/index.js --help
|
node dist/index.js --help
|
||||||
```
|
```
|
||||||
|
|
||||||
开发时初始化个人配置:
|
本项目要求 Node.js 20.11 或更高版本。正式 Release 已将运行代码和依赖打包,最终用户不需要源码开发环境。
|
||||||
|
|
||||||
```bash
|
|
||||||
node dist/index.js setup --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Authorize Key 只能输入到隐藏提示。不要把 Key 放进参数、环境变量、聊天、`config.json`、MCP 客户端配置或日志。
|
|
||||||
|
|
||||||
可用命令:
|
|
||||||
|
|
||||||
```text
|
|
||||||
nocobase-crm-mcp setup [--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 客户端配置。API 地址固定为 `https://justconn.top/api`,不接受安装参数或凭证文件覆盖;跨域重定向会在转发 Authorization 前被拒绝,错误内容会脱敏。
|
|
||||||
|
|
||||||
内部结构见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md),运维与权限验收见 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
|
|||||||
## 模块边界
|
## 模块边界
|
||||||
|
|
||||||
- `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。
|
- `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。
|
||||||
- `src/credentials.ts` 固定 `https://justconn.top/api`、负责隐藏 TTY 输入和用户级私有凭证文件。凭证文件只保存 Key;Key 不进入 argv、环境变量、CRM 配置或 MCP 配置。
|
- `src/nocobase-endpoints.ts` 集中固定 API、OAuth issuer、发现地址和 resource identifier。
|
||||||
|
- `src/oauth.ts` 实现 OAuth 发现、动态公共客户端注册、设备授权、浏览器启动、轮询和刷新;不读取浏览器 Cookie,也不监听本机端口。
|
||||||
|
- `src/credentials.ts` 管理用户级私有凭证文件与进程内缓存。默认保存 OAuth 访问/刷新令牌,兼容旧版个人 Key;任何凭据都不进入 argv、环境变量、CRM 配置或 MCP 配置。
|
||||||
- `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。
|
- `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。
|
||||||
- `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。
|
- `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。
|
||||||
- `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。
|
- `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。
|
||||||
@@ -17,8 +19,10 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
|
|||||||
|
|
||||||
## 身份与网络安全
|
## 身份与网络安全
|
||||||
|
|
||||||
- 每个请求重新读取当前 OS 用户的凭证文件,NocoBase ACL 是最终权限边界。
|
- 凭据在进程内缓存,只有访问令牌临近过期时才进行一次并发合并的刷新;NocoBase ACL 是最终权限边界。
|
||||||
- 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。
|
- 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。
|
||||||
|
- OAuth issuer、协议端点和浏览器授权 URL 必须属于固定的 `https://justconn.top` 来源。
|
||||||
|
- Device Authorization 请求 `api offline_access`;密码和浏览器会话不会离开 NocoBase。
|
||||||
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
|
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
|
||||||
- Token 会从 HTTP 错误中替换为 `<redacted>`。
|
- Token 会从 HTTP 错误中替换为 `<redacted>`。
|
||||||
- stdio 是唯一默认传输,不创建后台服务或监听端口。
|
- stdio 是唯一默认传输,不创建后台服务或监听端口。
|
||||||
|
|||||||
+11
-9
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## 交付模型
|
## 交付模型
|
||||||
|
|
||||||
每位用户在自己的电脑运行 MCP,并使用绑定本人业务身份的个人 Authorize Key:
|
每位用户在自己的电脑运行 MCP,并通过 NocoBase 原生 OAuth 登录本人业务身份:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
本机 AI 客户端 → 本机 TypeScript MCP → https://justconn.top/api → 本人 ACL
|
本机 AI 客户端 → 本机 TypeScript MCP → https://justconn.top/api → 本人 ACL
|
||||||
```
|
```
|
||||||
|
|
||||||
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。
|
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。个人 Authorize Key 仅作为显式备用认证方式。
|
||||||
|
|
||||||
## Release 发布物
|
## Release 发布物
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查
|
|||||||
- npm tarball;
|
- npm tarball;
|
||||||
- `SHA256SUMS`。
|
- `SHA256SUMS`。
|
||||||
|
|
||||||
源码仓库保持 Private 时,外部用户必须具有 Release 下载权限,或通过另一个经过身份认证的分发渠道取得相同资产。Private GitHub 链接本身不能成为任意用户可访问的一键安装链接。
|
Gitea 仓库访问受限时,外部用户必须具有 Release 下载权限,或通过另一个经过身份认证的分发渠道取得相同资产。受限仓库链接本身不能成为任意用户可访问的一键安装链接。
|
||||||
|
|
||||||
## 用户电脑上的内容
|
## 用户电脑上的内容
|
||||||
|
|
||||||
@@ -33,24 +33,26 @@ Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查
|
|||||||
|
|
||||||
至少使用两个权限不同的真实测试账号:
|
至少使用两个权限不同的真实测试账号:
|
||||||
|
|
||||||
1. 分别签发个人 Key,并在隔离的 OS 用户/凭证路径运行 setup。
|
1. 分别使用两个账号在隔离的 OS 用户/凭证路径运行 setup,并在 NocoBase 网页授权。
|
||||||
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups`、`list_projects`。
|
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups`、`list_projects`。
|
||||||
3. 确认返回范围与各自在 NocoBase UI 中的 ACL 一致。
|
3. 确认返回范围与各自在 NocoBase UI 中的 ACL 一致。
|
||||||
4. 无项目写权限账号在 `--enable-writes` 下调用 `create_project`,确认 NocoBase 拒绝。
|
4. 无项目写权限账号在 `--enable-writes` 下调用 `create_project`,确认 NocoBase 拒绝。
|
||||||
5. 有权限账号创建和更新测试项目,确认审计身份属于该用户。
|
5. 有权限账号创建和更新测试项目,确认审计身份属于该用户。
|
||||||
6. 撤销 Key 后确认 MCP 返回 `nocobase_auth_failed`,且不会降级使用其他凭证。
|
6. 让其中一个账号的 OAuth 会话失效,确认 MCP 自动刷新失败后返回 `nocobase_auth_failed`,且不会降级使用其他凭证。
|
||||||
7. 检查错误与日志,确认没有 Key、Authorization header 或业务原始行泄漏。
|
7. 检查错误与日志,确认没有访问令牌、刷新令牌、Authorization header、Key 或业务原始行泄漏。
|
||||||
|
|
||||||
## 更新与回滚
|
## 更新与回滚
|
||||||
|
|
||||||
Agent 下载新的固定 Release tarball并验证 SHA-256,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev <tarball>`。安装前备份 MCP 客户端配置和非秘密 CRM 配置。
|
Agent 下载新的固定 Release tarball并验证 SHA-256,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev <tarball>`。安装前备份 MCP 客户端配置和非秘密 CRM 配置。
|
||||||
|
|
||||||
回滚时重新安装上一固定 Release 的已验证 tarball。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 Key。
|
回滚时重新安装上一固定 Release 的已验证 tarball。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 OAuth 凭据。
|
||||||
|
|
||||||
## 常见故障
|
## 常见故障
|
||||||
|
|
||||||
- `nocobase_auth_failed`:重新运行 setup,让用户在隐藏提示输入新签发 Key。
|
- `nocobase_auth_failed`:重新运行 setup,让用户在 NocoBase 网页重新授权。
|
||||||
|
- 浏览器无法启动:使用 `setup --no-browser` 并手动打开显示的授权链接。
|
||||||
|
- OAuth 服务暂时不可用:先报告服务状态;只有管理员明确要求时才使用 `setup --manual-key`。
|
||||||
- `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。
|
- `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。
|
||||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
|
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
|
||||||
- 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。
|
- 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。
|
||||||
- 跨源重定向错误:检查 `https://justconn.top/api` 的反向代理配置;不要关闭同源保护。
|
- OAuth 来源校验错误:检查 `https://justconn.top/api` 的反向代理与发现元数据;不要放宽固定来源保护。
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 命令与工具参考
|
||||||
|
|
||||||
|
本文面向集成者和维护者。普通用户请从仓库根目录的 [README](../README.md) 开始,安装 Agent 请使用固定 Release Tag 中的 [INSTALL.md](../INSTALL.md)。
|
||||||
|
|
||||||
|
## 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`:输出可合并到 MCP 客户端的配置片段,不包含任何 Token 或 Key。
|
||||||
|
- `--enable-writes`:显式增加受控项目写入工具;默认不启用。
|
||||||
|
- `--no-browser`:不自动启动浏览器,只显示 NocoBase 授权链接和确认码。
|
||||||
|
- `--manual-key`:仅在 OAuth 不可用时,通过安全 TTY 隐藏输入个人 Authorize Key。
|
||||||
|
|
||||||
|
## 默认只读工具
|
||||||
|
|
||||||
|
默认稳定提供 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 原始记录。
|
||||||
|
|
||||||
|
## 可选工具
|
||||||
|
|
||||||
|
设置 `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 登录
|
||||||
|
|
||||||
|
- 发现地址:`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
|
||||||
|
```
|
||||||
|
|
||||||
|
- 启动后凭据缓存在进程内存中;日常请求不重复读取文件,也不增加 OAuth 网络请求。
|
||||||
|
- 只有访问令牌临近过期时才刷新,并把轮换后的凭据写回私有文件。
|
||||||
|
- 默认不创建后台服务,不监听本机或公网端口。
|
||||||
|
- NocoBase ACL、字段权限和行级权限是最终权限边界。
|
||||||
|
- 认证失败、超时和上游请求错误会以结构化错误返回。
|
||||||
|
|
||||||
|
实现细节见 [内部架构](ARCHITECTURE.md),运维和真实权限验收见 [本机交付与权限验收](LOCAL_DELIVERY.md)。
|
||||||
+14
-6
@@ -2,9 +2,9 @@
|
|||||||
"schemaVersion": 2,
|
"schemaVersion": 2,
|
||||||
"id": "com.nocobase.crm-mcp",
|
"id": "com.nocobase.crm-mcp",
|
||||||
"name": "NocoBase CRM MCP",
|
"name": "NocoBase CRM MCP",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"distribution": {
|
"distribution": {
|
||||||
"repository": "https://github.com/Donaldttt/nocobase-mcp",
|
"repository": "https://git.justconn.top/admin/nocobase-mcp",
|
||||||
"releaseRef": "must-match-v<version>",
|
"releaseRef": "must-match-v<version>",
|
||||||
"guide": "INSTALL.md from the exact Release tag",
|
"guide": "INSTALL.md from the exact Release tag",
|
||||||
"assets": ["donaldttt-nocobase-crm-mcp-<version>.tgz", "SHA256SUMS"],
|
"assets": ["donaldttt-nocobase-crm-mcp-<version>.tgz", "SHA256SUMS"],
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
},
|
},
|
||||||
"nocobase": {
|
"nocobase": {
|
||||||
"apiBaseUrl": "https://justconn.top/api",
|
"apiBaseUrl": "https://justconn.top/api",
|
||||||
"userConfigurableEndpoint": false
|
"userConfigurableEndpoint": false,
|
||||||
|
"oauthIssuer": "https://justconn.top/api",
|
||||||
|
"oauthResource": "https://justconn.top/api/"
|
||||||
},
|
},
|
||||||
"installation": {
|
"installation": {
|
||||||
"requiredTools": ["node", "npm"],
|
"requiredTools": ["node", "npm"],
|
||||||
@@ -36,8 +38,13 @@
|
|||||||
"defaultBackgroundService": false
|
"defaultBackgroundService": false
|
||||||
},
|
},
|
||||||
"authentication": {
|
"authentication": {
|
||||||
"type": "personal-authorize-key",
|
"type": "oauth-2.1-device-authorization",
|
||||||
"secretEntry": "user-controlled-hidden-tty",
|
"discovery": "https://justconn.top/api/.well-known/openid-configuration",
|
||||||
|
"scopes": ["api", "offline_access"],
|
||||||
|
"dynamicClientRegistration": true,
|
||||||
|
"browserSignIn": true,
|
||||||
|
"automaticRefresh": true,
|
||||||
|
"manualFallback": "user-controlled-hidden-tty-personal-authorize-key",
|
||||||
"storage": "per-user private local credential file",
|
"storage": "per-user private local credential file",
|
||||||
"posixFileMode": "0600",
|
"posixFileMode": "0600",
|
||||||
"allowPassword": false,
|
"allowPassword": false,
|
||||||
@@ -58,6 +65,7 @@
|
|||||||
"sharedNocobaseCredential": false,
|
"sharedNocobaseCredential": false,
|
||||||
"networkBind": "none-stdio",
|
"networkBind": "none-stdio",
|
||||||
"remoteHttpRequiresTls": true,
|
"remoteHttpRequiresTls": true,
|
||||||
"crossOriginAuthRedirect": false
|
"crossOriginAuthRedirect": false,
|
||||||
|
"browserCredentialRead": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@donaldttt/nocobase-crm-mcp",
|
"name": "@donaldttt/nocobase-crm-mcp",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@donaldttt/nocobase-crm-mcp",
|
"name": "@donaldttt/nocobase-crm-mcp",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"bin": {
|
"bin": {
|
||||||
"nocobase-crm-mcp": "dist/index.js"
|
"nocobase-crm-mcp": "dist/index.js"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@donaldttt/nocobase-crm-mcp",
|
"name": "@donaldttt/nocobase-crm-mcp",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Local NocoBase CRM MCP server using each user's personal Authorize Key",
|
"description": "Local NocoBase CRM MCP server with per-user NocoBase OAuth browser sign-in",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+86
-15
@@ -4,14 +4,17 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import {
|
import {
|
||||||
|
createCredentialProvider,
|
||||||
credentialsPath,
|
credentialsPath,
|
||||||
loadCredentials,
|
loadStoredCredentials,
|
||||||
NOCOBASE_API_BASE_URL,
|
NOCOBASE_API_BASE_URL,
|
||||||
readHidden,
|
readHidden,
|
||||||
saveCredentials,
|
saveCredentials,
|
||||||
|
saveOAuthCredentials,
|
||||||
} from "./credentials.js";
|
} from "./credentials.js";
|
||||||
import { CrmService } from "./crm-service.js";
|
import { CrmService } from "./crm-service.js";
|
||||||
import { NocoBaseClient } from "./nocobase-client.js";
|
import { NocoBaseClient } from "./nocobase-client.js";
|
||||||
|
import { loginWithBrowser } from "./oauth.js";
|
||||||
import { ProjectService } from "./project-service.js";
|
import { ProjectService } from "./project-service.js";
|
||||||
import { createServer } from "./tools.js";
|
import { createServer } from "./tools.js";
|
||||||
import { VERSION } from "./version.js";
|
import { VERSION } from "./version.js";
|
||||||
@@ -21,6 +24,8 @@ interface ParsedArgs {
|
|||||||
config?: string;
|
config?: string;
|
||||||
enableWrites: boolean;
|
enableWrites: boolean;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
|
manualKey: boolean;
|
||||||
|
noBrowser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||||
@@ -70,7 +75,7 @@ export function createRuntime(configPath: string): {
|
|||||||
projects: ProjectService;
|
projects: ProjectService;
|
||||||
} {
|
} {
|
||||||
const config = loadConfig(configPath);
|
const config = loadConfig(configPath);
|
||||||
const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource });
|
const client = new NocoBaseClient(createCredentialProvider(), { dataSource: config.dataSource });
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
crm: new CrmService(config, client),
|
crm: new CrmService(config, client),
|
||||||
@@ -79,16 +84,48 @@ export function createRuntime(configPath: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setup(args: ParsedArgs): Promise<void> {
|
async function setup(args: ParsedArgs): Promise<void> {
|
||||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
let authorizeKey: string;
|
||||||
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
|
let authentication: "oauth-browser" | "personal-authorize-key-fallback";
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
process.stderr.write(
|
||||||
|
`Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` +
|
||||||
|
"Your password and browser session stay inside NocoBase.\n",
|
||||||
|
);
|
||||||
|
const oauthCredentials = await loginWithBrowser({
|
||||||
|
clientId: reusableOAuthClientId(),
|
||||||
|
openBrowser: args.noBrowser ? async () => false : undefined,
|
||||||
|
onAuthorization: ({ opened, url, userCode, expiresIn }) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`${opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${url}\n` +
|
||||||
|
`Confirmation code: ${userCode}\n` +
|
||||||
|
`Complete sign-in within ${Math.ceil(expiresIn / 60)} minutes. Waiting for approval...\n`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authorizeKey = oauthCredentials.accessToken;
|
||||||
|
authentication = "oauth-browser";
|
||||||
|
const verificationClient = new NocoBaseClient(() => ({
|
||||||
|
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||||
|
authorizeKey,
|
||||||
|
}));
|
||||||
|
await verificationClient.verifyCredentials();
|
||||||
|
const configPath = ensureConfig(args.config);
|
||||||
|
saveOAuthCredentials(oauthCredentials);
|
||||||
|
await printSetupResult(args, configPath, authentication);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
process.stderr.write(
|
|
||||||
`This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` +
|
|
||||||
"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(() => ({
|
const verificationClient = new NocoBaseClient(() => ({
|
||||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||||
authorizeKey,
|
authorizeKey,
|
||||||
@@ -96,14 +133,26 @@ async function setup(args: ParsedArgs): Promise<void> {
|
|||||||
await verificationClient.verifyCredentials();
|
await verificationClient.verifyCredentials();
|
||||||
const configPath = ensureConfig(args.config);
|
const configPath = ensureConfig(args.config);
|
||||||
saveCredentials({ authorizeKey });
|
saveCredentials({ authorizeKey });
|
||||||
|
await printSetupResult(args, configPath, authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printSetupResult(
|
||||||
|
args: ParsedArgs,
|
||||||
|
configPath: string,
|
||||||
|
authentication: "oauth-browser" | "personal-authorize-key-fallback",
|
||||||
|
): Promise<void> {
|
||||||
const runtime = createRuntime(configPath);
|
const runtime = createRuntime(configPath);
|
||||||
const health = await runtime.crm.crmHealth();
|
const health = await runtime.crm.crmHealth();
|
||||||
printResult(
|
printResult(
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||||
|
authentication,
|
||||||
credentialsPath: credentialsPath(),
|
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,
|
configPath,
|
||||||
mode: args.enableWrites ? "fixed-project-writes" : "read-only",
|
mode: args.enableWrites ? "fixed-project-writes" : "read-only",
|
||||||
clientConfig: clientConfig(configPath, args.enableWrites),
|
clientConfig: clientConfig(configPath, args.enableWrites),
|
||||||
@@ -113,6 +162,13 @@ 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 {
|
function ensureConfig(explicit?: string): string {
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
const path = resolve(explicit);
|
const path = resolve(explicit);
|
||||||
@@ -159,13 +215,21 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|||||||
command = first as ParsedArgs["command"];
|
command = first as ParsedArgs["command"];
|
||||||
index = 1;
|
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) {
|
while (index < argv.length) {
|
||||||
const flag = argv[index];
|
const flag = argv[index];
|
||||||
if (flag === "--help" || flag === "-h") parsed.command = "help";
|
if (flag === "--help" || flag === "-h") parsed.command = "help";
|
||||||
else if (flag === "--version" || flag === "-v") parsed.command = "version";
|
else if (flag === "--version" || flag === "-v") parsed.command = "version";
|
||||||
else if (flag === "--enable-writes") parsed.enableWrites = true;
|
else if (flag === "--enable-writes") parsed.enableWrites = true;
|
||||||
else if (flag === "--json") parsed.json = true;
|
else if (flag === "--json") parsed.json = true;
|
||||||
|
else if (flag === "--manual-key") parsed.manualKey = true;
|
||||||
|
else if (flag === "--no-browser") parsed.noBrowser = true;
|
||||||
else if (flag === "--config") {
|
else if (flag === "--config") {
|
||||||
const value = argv[index + 1];
|
const value = argv[index + 1];
|
||||||
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
|
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
|
||||||
@@ -174,6 +238,11 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|||||||
} else throw new Error(`Unknown option: ${flag}`);
|
} else throw new Error(`Unknown option: ${flag}`);
|
||||||
index += 1;
|
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;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,13 +254,15 @@ function helpText(): string {
|
|||||||
return `NocoBase CRM MCP ${VERSION}
|
return `NocoBase CRM MCP ${VERSION}
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
nocobase-crm-mcp setup [--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 [serve] [--config <path>] [--enable-writes]
|
||||||
nocobase-crm-mcp doctor [--config <path>] [--json]
|
nocobase-crm-mcp doctor [--config <path>] [--json]
|
||||||
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||||
|
|
||||||
The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}.
|
The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}.
|
||||||
The setup command accepts the personal Authorize Key only through a hidden local TTY prompt.
|
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 default stdio server is read-only.
|
The default stdio server is read-only.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-22
@@ -1,6 +1,8 @@
|
|||||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
import { closeSync, fchmodSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, 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 { isRecord } from "./types.js";
|
import { isRecord } from "./types.js";
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
@@ -8,7 +10,22 @@ export interface Credentials {
|
|||||||
authorizeKey: string;
|
authorizeKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NOCOBASE_API_BASE_URL = "https://justconn.top/api";
|
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 function credentialsPath(): string {
|
export function credentialsPath(): string {
|
||||||
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
|
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
|
||||||
@@ -30,37 +47,88 @@ export function saveCredentials(
|
|||||||
): void {
|
): void {
|
||||||
const authorizeKey = credentials.authorizeKey.trim();
|
const authorizeKey = credentials.authorizeKey.trim();
|
||||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank");
|
if (!authorizeKey) throw new Error("Authorize Key must not be blank");
|
||||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
writePrivateCredentials(path, { authorizeKey });
|
||||||
if (process.platform !== "win32") {
|
}
|
||||||
try {
|
|
||||||
const directoryMode = statSync(dirname(path)).mode & 0o777;
|
export function saveOAuthCredentials(credentials: OAuthCredentials, path = credentialsPath()): void {
|
||||||
if ((directoryMode & 0o077) !== 0)
|
validateOAuthCredentials(credentials);
|
||||||
throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`);
|
writePrivateCredentials(path, credentials);
|
||||||
} catch (error) {
|
|
||||||
if (existsSync(dirname(path))) throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const descriptor = openSync(path, "w", 0o600);
|
|
||||||
try {
|
|
||||||
writeFileSync(descriptor, `${JSON.stringify({ authorizeKey }, null, 2)}\n`, "utf8");
|
|
||||||
} finally {
|
|
||||||
closeSync(descriptor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCredentials(path = credentialsPath()): Credentials {
|
export function loadCredentials(path = credentialsPath()): Credentials {
|
||||||
|
const stored = loadStoredCredentials(path);
|
||||||
|
return {
|
||||||
|
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||||
|
authorizeKey: stored.type === "oauth" ? stored.accessToken : stored.authorizeKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoredCredentials(path = credentialsPath()): StoredCredentials {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
const mode = statSync(path).mode & 0o777;
|
const mode = statSync(path).mode & 0o777;
|
||||||
if ((mode & 0o077) !== 0)
|
if ((mode & 0o077) !== 0)
|
||||||
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
|
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
|
||||||
}
|
}
|
||||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||||
if (!isRecord(parsed) || typeof parsed.authorizeKey !== "string") {
|
if (!isRecord(parsed)) throw new Error("Credential file is invalid; run setup again");
|
||||||
throw new Error("Credential file is invalid; run setup again");
|
if (parsed.type === "oauth") {
|
||||||
|
const oauth = parsed as unknown as OAuthCredentials;
|
||||||
|
validateOAuthCredentials(oauth);
|
||||||
|
return { ...oauth };
|
||||||
}
|
}
|
||||||
const authorizeKey = parsed.authorizeKey.trim();
|
const authorizeKey = typeof parsed.authorizeKey === "string" ? parsed.authorizeKey.trim() : "";
|
||||||
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again");
|
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again");
|
||||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey };
|
return { type: "personal-authorize-key", authorizeKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCredentialProvider(
|
||||||
|
options: CredentialProviderOptions = {},
|
||||||
|
): () => Promise<Credentials> {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return async (): Promise<Credentials> => {
|
||||||
|
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;
|
||||||
|
refreshInFlight ??= refresh(expiringCredentials)
|
||||||
|
.then((refreshed) => {
|
||||||
|
validateOAuthCredentials(refreshed);
|
||||||
|
saveOAuthCredentials(refreshed, path);
|
||||||
|
stored = refreshed;
|
||||||
|
refreshRetryAt = 0;
|
||||||
|
return refreshed;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshInFlight = undefined;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshInFlight;
|
||||||
|
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: refreshed.accessToken };
|
||||||
|
} catch (error) {
|
||||||
|
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; run setup again: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readHidden(prompt: string): Promise<string> {
|
export async function readHidden(prompt: string): Promise<string> {
|
||||||
@@ -98,3 +166,40 @@ export async function readHidden(prompt: string): Promise<string> {
|
|||||||
process.stdin.on("data", onData);
|
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
|
||||||
|
) {
|
||||||
|
throw new Error("OAuth credential file is invalid; run setup again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePrivateCredentials(path: string, value: unknown): 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(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`);
|
||||||
|
}
|
||||||
|
const descriptor = openSync(path, "w", 0o600);
|
||||||
|
try {
|
||||||
|
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
||||||
|
writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
} finally {
|
||||||
|
closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}/`;
|
||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 fetchImpl = options.fetch ?? fetch;
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
const sleep = options.sleep ?? defaultSleep;
|
||||||
|
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);
|
||||||
|
options.onAuthorization?.({
|
||||||
|
opened,
|
||||||
|
url: browserUrl,
|
||||||
|
userCode: authorization.userCode,
|
||||||
|
expiresIn: authorization.expiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await pollForTokens(discovery, clientId, authorization, {
|
||||||
|
fetch: fetchImpl,
|
||||||
|
now,
|
||||||
|
sleep,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 form = new URLSearchParams({
|
||||||
|
grant_type: DEVICE_GRANT,
|
||||||
|
client_id: clientId,
|
||||||
|
device_code: authorization.deviceCode,
|
||||||
|
resource: NOCOBASE_OAUTH_RESOURCE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const payload = await requestJson(
|
||||||
|
discovery.tokenEndpoint,
|
||||||
|
{ method: "POST", headers: formHeaders(), body: form },
|
||||||
|
options.fetch,
|
||||||
|
[authorization.deviceCode],
|
||||||
|
);
|
||||||
|
return tokenCredentials(payload, clientId, options.now, { requireRefreshToken: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OAuthProtocolError && error.code === "authorization_pending") continue;
|
||||||
|
if (error instanceof OAuthProtocolError && error.code === "slow_down") {
|
||||||
|
intervalSeconds = Math.min(intervalSeconds + 5, 30);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (error instanceof OAuthProtocolError && error.code === "access_denied") {
|
||||||
|
throw new OAuthProtocolError("NocoBase browser authorization was cancelled", error.code);
|
||||||
|
}
|
||||||
|
if (error instanceof OAuthProtocolError && error.code === "expired_token") break;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new OAuthProtocolError("NocoBase browser authorization expired; run setup again", "expired_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenCredentials(
|
||||||
|
payload: unknown,
|
||||||
|
clientId: string,
|
||||||
|
now: () => number,
|
||||||
|
fallback: { refreshToken?: string; scope?: string; requireRefreshToken?: boolean },
|
||||||
|
): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
+2
-2
@@ -26,7 +26,7 @@ export function createServer(options: {
|
|||||||
register(
|
register(
|
||||||
server,
|
server,
|
||||||
"crm_health",
|
"crm_health",
|
||||||
"Check NocoBase reachability, personal Key authentication, and CRM mapping.",
|
"Check NocoBase reachability, user authentication, and CRM mapping.",
|
||||||
{},
|
{},
|
||||||
() => crm.crmHealth(),
|
() => crm.crmHealth(),
|
||||||
);
|
);
|
||||||
@@ -413,7 +413,7 @@ async function safeTool(operation: () => Promise<ToolPayload>): Promise<ToolPayl
|
|||||||
error: {
|
error: {
|
||||||
code: "nocobase_auth_failed",
|
code: "nocobase_auth_failed",
|
||||||
message: error.message,
|
message: error.message,
|
||||||
hint: "Ask the local installation Agent to run setup, then enter a personal Authorize Key in the hidden prompt.",
|
hint: "Ask the local installation Agent to run setup, then complete NocoBase sign-in in the browser.",
|
||||||
retryable: false,
|
retryable: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 no runtime npm dependencies", () => {
|
||||||
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
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"));
|
const manifest = JSON.parse(readFileSync(new URL("../install-manifest.json", import.meta.url), "utf8"));
|
||||||
assert.equal(VERSION, "0.3.1");
|
assert.equal(VERSION, "0.4.0");
|
||||||
assert.equal(packageJson.version, VERSION);
|
assert.equal(packageJson.version, VERSION);
|
||||||
assert.equal(manifest.version, VERSION);
|
assert.equal(manifest.version, VERSION);
|
||||||
assert.deepEqual(packageJson.dependencies, undefined);
|
assert.deepEqual(packageJson.dependencies, undefined);
|
||||||
@@ -24,14 +24,14 @@ test("generated MCP config contains no Key, token, or environment secret", () =>
|
|||||||
assert.doesNotMatch(serialized, /authorize|token|secret|password/i);
|
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;
|
if (process.stdin.isTTY || process.stderr.isTTY) return;
|
||||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-nontty-"));
|
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-nontty-"));
|
||||||
const path = join(directory, "credentials.json");
|
const path = join(directory, "credentials.json");
|
||||||
const previous = process.env.NOCOBASE_MCP_CREDENTIALS;
|
const previous = process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||||
process.env.NOCOBASE_MCP_CREDENTIALS = path;
|
process.env.NOCOBASE_MCP_CREDENTIALS = path;
|
||||||
try {
|
try {
|
||||||
await assert.rejects(runCli(["setup", "--json"]), /local terminal/);
|
await assert.rejects(runCli(["setup", "--manual-key", "--json"]), /local terminal/);
|
||||||
assert.equal(existsSync(path), false);
|
assert.equal(existsSync(path), false);
|
||||||
assert.equal(existsSync(join(directory, "config.json")), false);
|
assert.equal(existsSync(join(directory, "config.json")), false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import assert from "node:assert/strict";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { DEFAULT_CONFIG, loadConfig } from "../src/config.js";
|
import { DEFAULT_CONFIG, loadConfig } from "../src/config.js";
|
||||||
import { loadCredentials, NOCOBASE_API_BASE_URL, saveCredentials } from "../src/credentials.js";
|
import {
|
||||||
|
createCredentialProvider,
|
||||||
|
loadCredentials,
|
||||||
|
loadStoredCredentials,
|
||||||
|
NOCOBASE_API_BASE_URL,
|
||||||
|
saveCredentials,
|
||||||
|
saveOAuthCredentials,
|
||||||
|
} from "../src/credentials.js";
|
||||||
|
|
||||||
test("stores only the Key and always loads the fixed NocoBase endpoint", () => {
|
test("stores only the Key and always loads the fixed NocoBase endpoint", () => {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credentials-"));
|
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credentials-"));
|
||||||
@@ -24,6 +31,84 @@ test("stores only the Key and always loads the fixed NocoBase endpoint", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("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", () => {
|
test("loads camelCase and snake_case config aliases without mutating defaults", () => {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-config-"));
|
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-config-"));
|
||||||
const path = join(directory, "config.json");
|
const path = join(directory, "config.json");
|
||||||
@@ -50,3 +135,7 @@ test("rejects malformed config types before any NocoBase request", () => {
|
|||||||
writeFileSync(path, JSON.stringify({ collections: { customers: { fields: "id,name" } } }));
|
writeFileSync(path, JSON.stringify({ collections: { customers: { fields: "id,name" } } }));
|
||||||
assert.throws(() => loadConfig(path), /array of strings/);
|
assert.throws(() => loadConfig(path), /array of strings/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function statMode(path: string): number {
|
||||||
|
return statSync(path).mode & 0o777;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { type AuthorizationPrompt, loginWithBrowser, refreshOAuthCredentials } 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.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("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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user