feat: add NocoBase browser OAuth login

This commit is contained in:
nac
2026-08-18 04:08:27 +00:00
parent 5c2cbac5b1
commit 4c53f90947
16 changed files with 1138 additions and 143 deletions
+36 -20
View File
@@ -1,23 +1,23 @@
# 安装 NocoBase CRM MCP
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在隐藏提示中输入自己的 NocoBase Authorize Key。
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在浏览器中登录自己的 NocoBase 账号完成授权。
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更或等待用户在 NocoBase 网页登录授权时才中断用户。
## 完成标准
- 下载本文件所在的同一固定 Release,不使用默认分支;
- 只连接固定的 NocoBase API `https://justconn.top/api`;
- MCP 在用户本机以 stdio 运行,默认只读;
- 认证属于当前用户自己的个人 Authorize Key 和业务角色;
- 通过 NocoBase 原生 OAuth 2.1 认证当前用户,权限仍跟随本人的业务角色;
- 当前 MCP 客户端已注册 `nocobase-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、包管理器或其他系统工具。
- 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。
@@ -35,7 +35,7 @@
- `donaldttt-nocobase-crm-mcp-<version>.tgz`;
- `SHA256SUMS`。
仓库为 Private 时,必须使用当前用户已经授权的 GitHub 连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户 GitHub Token 到聊天。
仓库访问受限时,必须使用当前用户已经授权的代码托管平台连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户的访问令牌到聊天。
### 2. 一次性说明本机变更
@@ -46,7 +46,7 @@
正常安装只会留下:
- 应用数据目录中的 npm 包;
- 不含 Key 的 CRM `config.json`;
- 不含凭据的 CRM `config.json`;
- 权限受限的本机凭证文件;
- MCP 客户端配置及其备份。
@@ -54,7 +54,7 @@
### 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`。
### 4. 个人 Key 设置
### 4. 浏览器登录 NocoBase
先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的个人 Authorize Key。不要让用户把 Key 发给 Agent。
在用户可直接控制的本机终端运行:
运行:
```bash
<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
@@ -94,7 +108,7 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当
4. 重载或重启 MCP 客户端;
5. 调用 `crm_health`,要求 `ok=true`。
最终只报告 Release、安装目录、配置备份位置和只读/写入模式,不显示任何 Key。
最终只报告 Release、安装目录、配置备份位置、认证方式和只读/写入模式,不显示任何 Token 或 Key。
## 可选写入
@@ -102,14 +116,16 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当
## 故障处理
- `nocobase_auth_failed`:让用户撤销失效 Key,重新运行同一个 setup 命令并在隐藏提示输入新 Key。
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限 Key。
- 浏览器未打开:使用 `setup --no-browser`,让用户手动打开终端显示的 `justconn.top` 授权链接。
- 授权超时或被取消:重新运行 `setup`,再次核对确认码并授权。
- `nocobase_auth_failed`:重新运行 `setup` 完成浏览器登录;不要换用共享高权限 Token 或 Key。
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限身份。
- Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。
- Private Release 无权访问:报告分发权限问题,不退回 clone 默认分支。
- 受限 Release 无权访问:报告 Gitea 或分发渠道的权限问题,不退回 clone 默认分支。
## 用户可发送给 Agent 的最短指令
```text
请按这个固定 Release 的协议帮我完成本机安装。默认只读,使用我自己的 NocoBase Authorize Key;只让我在本机隐藏提示中输入 Key,不要静默安装系统工具。完成前必须验证 crm_health:
请按这个固定 Release 的协议帮我完成本机安装。默认只读;请运行 setup,让我只在 NocoBase 官方网页登录并确认授权。不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前必须验证 crm_health:
<固定 Release 的 INSTALL.md 链接>
```
+83 -58
View File
@@ -1,34 +1,95 @@
# NocoBase CRM MCP
本项目是在用户电脑本机运行的 NocoBase CRM MCP。每位用户使用自己的个人 Authorize Key,所有查询和写入继续由 NocoBase ACL、字段权限和行级权限裁决;不需要共享账号或远程 MCP 服务。
## 用户侧依赖
正式 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 能认证访问的下载地址。
在你的电脑上,让 AI 客户端安全地查询 NocoBase CRM。安装时通过浏览器登录 NocoBase;每位用户使用自己的身份,看到的数据范围与本人在 NocoBase 中的权限一致。
## 安装
把对应 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
npm ci
npm run check
@@ -37,40 +98,4 @@ npm run build
node dist/index.js --help
```
开发时初始化个人配置:
```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)。
本项目要求 Node.js 20.11 或更高版本。正式 Release 已将运行代码和依赖打包,最终用户不需要源码开发环境。
+6 -2
View File
@@ -9,7 +9,9 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
## 模块边界
- `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/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。
- `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`,不接受参数、配置或凭证文件覆盖。
- OAuth issuer、协议端点和浏览器授权 URL 必须属于固定的 `https://justconn.top` 来源。
- Device Authorization 请求 `api offline_access`;密码和浏览器会话不会离开 NocoBase。
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
- Token 会从 HTTP 错误中替换为 `<redacted>`。
- stdio 是唯一默认传输,不创建后台服务或监听端口。
+11 -9
View File
@@ -2,13 +2,13 @@
## 交付模型
每位用户在自己的电脑运行 MCP,并使用绑定本人业务身份的个人 Authorize Key:
每位用户在自己的电脑运行 MCP,并通过 NocoBase 原生 OAuth 登录本人业务身份:
```text
本机 AI 客户端 → 本机 TypeScript MCP → https://justconn.top/api → 本人 ACL
```
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。个人 Authorize Key 仅作为显式备用认证方式。
## Release 发布物
@@ -17,7 +17,7 @@ Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查
- npm tarball;
- `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`。
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 配置。
回滚时重新安装上一固定 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_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
- 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。
- 跨源重定向错误:检查 `https://justconn.top/api` 的反向代理配置;不要关闭同源保护。
- OAuth 来源校验错误:检查 `https://justconn.top/api` 的反向代理与发现元数据;不要放宽固定来源保护。
+88
View File
@@ -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
View File
@@ -2,9 +2,9 @@
"schemaVersion": 2,
"id": "com.nocobase.crm-mcp",
"name": "NocoBase CRM MCP",
"version": "0.3.1",
"version": "0.4.0",
"distribution": {
"repository": "https://github.com/Donaldttt/nocobase-mcp",
"repository": "https://git.justconn.top/admin/nocobase-mcp",
"releaseRef": "must-match-v<version>",
"guide": "INSTALL.md from the exact Release tag",
"assets": ["donaldttt-nocobase-crm-mcp-<version>.tgz", "SHA256SUMS"],
@@ -21,7 +21,9 @@
},
"nocobase": {
"apiBaseUrl": "https://justconn.top/api",
"userConfigurableEndpoint": false
"userConfigurableEndpoint": false,
"oauthIssuer": "https://justconn.top/api",
"oauthResource": "https://justconn.top/api/"
},
"installation": {
"requiredTools": ["node", "npm"],
@@ -36,8 +38,13 @@
"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,
"manualFallback": "user-controlled-hidden-tty-personal-authorize-key",
"storage": "per-user private local credential file",
"posixFileMode": "0600",
"allowPassword": false,
@@ -58,6 +65,7 @@
"sharedNocobaseCredential": false,
"networkBind": "none-stdio",
"remoteHttpRequiresTls": true,
"crossOriginAuthRedirect": false
"crossOriginAuthRedirect": false,
"browserCredentialRead": false
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.1",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.1",
"version": "0.4.0",
"license": "UNLICENSED",
"bin": {
"nocobase-crm-mcp": "dist/index.js"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.1",
"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",
+81 -10
View File
@@ -4,14 +4,17 @@ import { fileURLToPath } from "node:url";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadConfig } from "./config.js";
import {
createCredentialProvider,
credentialsPath,
loadCredentials,
loadStoredCredentials,
NOCOBASE_API_BASE_URL,
readHidden,
saveCredentials,
saveOAuthCredentials,
} from "./credentials.js";
import { CrmService } from "./crm-service.js";
import { NocoBaseClient } from "./nocobase-client.js";
import { loginWithBrowser } from "./oauth.js";
import { ProjectService } from "./project-service.js";
import { createServer } from "./tools.js";
import { VERSION } from "./version.js";
@@ -21,6 +24,8 @@ interface ParsedArgs {
config?: string;
enableWrites: boolean;
json: boolean;
manualKey: boolean;
noBrowser: boolean;
}
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
@@ -70,7 +75,7 @@ export function createRuntime(configPath: string): {
projects: ProjectService;
} {
const config = loadConfig(configPath);
const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource });
const client = new NocoBaseClient(createCredentialProvider(), { dataSource: config.dataSource });
return {
client,
crm: new CrmService(config, client),
@@ -79,16 +84,48 @@ export function createRuntime(configPath: string): {
}
async function setup(args: ParsedArgs): Promise<void> {
let authorizeKey: string;
let authentication: "oauth-browser" | "personal-authorize-key-fallback";
if (args.manualKey) {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
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` +
"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",
"Enter a revocable personal Authorize Key only in the hidden prompt below.\n" +
"Do not paste the Key into Agent chat.\n",
);
const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
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;
}
const verificationClient = new NocoBaseClient(() => ({
apiBaseUrl: NOCOBASE_API_BASE_URL,
authorizeKey,
@@ -96,14 +133,26 @@ async function setup(args: ParsedArgs): Promise<void> {
await verificationClient.verifyCredentials();
const configPath = ensureConfig(args.config);
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 health = await runtime.crm.crmHealth();
printResult(
{
ok: true,
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),
@@ -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 {
if (explicit) {
const path = resolve(explicit);
@@ -159,13 +215,21 @@ 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 === "--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`);
@@ -174,6 +238,11 @@ function parseArgs(argv: string[]): ParsedArgs {
} 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,13 +254,15 @@ function helpText(): string {
return `NocoBase CRM MCP ${VERSION}
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 doctor [--config <path>] [--json]
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
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.
`;
}
+127 -22
View File
@@ -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 { 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";
export interface Credentials {
@@ -8,7 +10,22 @@ export interface Credentials {
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 {
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
@@ -30,37 +47,88 @@ export function saveCredentials(
): void {
const authorizeKey = credentials.authorizeKey.trim();
if (!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({ authorizeKey }, null, 2)}\n`, "utf8");
} finally {
closeSync(descriptor);
}
writePrivateCredentials(path, { authorizeKey });
}
export function saveOAuthCredentials(credentials: OAuthCredentials, path = credentialsPath()): void {
validateOAuthCredentials(credentials);
writePrivateCredentials(path, 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") {
const mode = statSync(path).mode & 0o777;
if ((mode & 0o077) !== 0)
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
}
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed) || typeof parsed.authorizeKey !== "string") {
throw new Error("Credential file is invalid; run setup again");
if (!isRecord(parsed)) 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");
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> {
@@ -98,3 +166,40 @@ 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
) {
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);
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -26,7 +26,7 @@ export function createServer(options: {
register(
server,
"crm_health",
"Check NocoBase reachability, personal Key authentication, and CRM mapping.",
"Check NocoBase reachability, user authentication, and CRM mapping.",
{},
() => crm.crmHealth(),
);
@@ -413,7 +413,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: "Ask the local installation Agent to run setup, then complete NocoBase sign-in in the browser.",
retryable: false,
},
};
+3 -3
View File
@@ -9,7 +9,7 @@ import { VERSION } from "../src/version.js";
test("package, manifest, and runtime versions agree with no runtime npm dependencies", () => {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const manifest = JSON.parse(readFileSync(new URL("../install-manifest.json", import.meta.url), "utf8"));
assert.equal(VERSION, "0.3.1");
assert.equal(VERSION, "0.4.0");
assert.equal(packageJson.version, VERSION);
assert.equal(manifest.version, VERSION);
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);
});
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", "--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 {
+91 -2
View File
@@ -1,10 +1,17 @@
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, 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", () => {
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", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-config-"));
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" } } }));
assert.throws(() => loadConfig(path), /array of strings/);
});
function statMode(path: string): number {
return statSync(path).mode & 0o777;
}
+150
View File
@@ -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" },
});
}