feat: add MCP-guided browser login
This commit is contained in:
+41
-25
@@ -102,27 +102,53 @@ npm install --prefix <install-prefix> --omit=dev <locally-built-commit.tgz>
|
||||
|
||||
定位安装后的命令:POSIX 为 `<install-prefix>/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。运行 `--version` 和 `--help`,确认版本等于源码 `package.json`,并且帮助中包含本 Commit 安装协议使用的 `setup --no-browser` 选项;不一致时停止,不得继续注册旧二进制。
|
||||
|
||||
### 4. 浏览器登录 NocoBase
|
||||
### 4. 在登录前注册 MCP
|
||||
|
||||
运行:
|
||||
先运行:
|
||||
|
||||
```bash
|
||||
<installed-command> setup --json
|
||||
<installed-command> client-config --json
|
||||
```
|
||||
|
||||
安装程序通过 NocoBase 的 OAuth 发现接口动态注册本机公共客户端,请求 `api offline_access` 权限,并自动打开带确认码的 NocoBase 设备授权页。用户在网页中登录、核对确认码并点击授权;程序随后取得短期访问令牌和可撤销的刷新令牌,通过 `https://justconn.top/api/roles:check` 验证成功后保存,并立即执行 `crm_health`。
|
||||
该命令会在需要时从包内模板准备默认 `config.json`,不要求凭据,也不会发起 OAuth。读取返回的 `clientConfig`:
|
||||
|
||||
浏览器没有自动打开时,安装程序会继续显示完整授权链接和确认码。可重新运行:
|
||||
1. 展示客户端配置路径和新增的 `nocobase-crm` 条目;
|
||||
2. 创建可恢复备份;
|
||||
3. 结构化合并该 server,保留所有其他配置;
|
||||
4. 重载或重启 MCP 客户端,并在客户端要求时让用户信任该连接器。
|
||||
|
||||
没有凭据时 stdio MCP 仍应正常启动并响应 `tools/list`。此时业务工具会返回结构化的 `nocobase_auth_failed`,而不是导致整个 MCP 进程退出。
|
||||
|
||||
### 5. 通过 MCP 把登录链接交给用户
|
||||
|
||||
注册完成后调用:
|
||||
|
||||
```text
|
||||
nocobase_login({})
|
||||
```
|
||||
|
||||
该工具通过 NocoBase OAuth 发现接口动态注册本机公共客户端,请求 `api offline_access`,并立即返回:
|
||||
|
||||
- `login_url`:只指向 `https://justconn.top` 的完整授权链接;
|
||||
- `user_code`:供用户在页面核对的确认码;
|
||||
- `expires_in_seconds`:剩余有效时间;
|
||||
- `browser_opened`:桌面浏览器是否已成功自动打开。
|
||||
|
||||
无论 `browser_opened` 是真是假,都应立即把 `login_url`、`user_code` 和有效期发给用户。用户只在 NocoBase 网页输入账号信息、核对确认码并点击授权。随后调用 `nocobase_login_status({})`;授权仍在等待时它会安全地重复同一链接,成功时返回 `status=authenticated`。不要高频轮询;可在用户确认完成后或按合理间隔查询。
|
||||
|
||||
取得 `authenticated` 后调用 `crm_health`,要求 `ok=true`。如果已有凭据但 `crm_health` 明确返回认证失败,调用 `nocobase_login({"force": true})` 创建新的授权,不得换用共享高权限 Token 或 Key。
|
||||
|
||||
这一流程同时适用于桌面 GUI、Hermes Agent、OpenClaw 和远程无界面服务器:桌面环境会尝试自动打开浏览器;远程环境直接把同一个 HTTPS 链接经当前 Messenger 或聊天通道交给用户。后台轮询、凭据验证和保存都由 MCP 进程完成,Agent 只能看到安全的链接、确认码和状态,看不到 OAuth Token 或设备码。一个 MCP 进程只能服务一个受信任的用户身份;多用户必须使用相互隔离的 Agent/MCP 实例和凭据路径。
|
||||
|
||||
设备授权约十分钟后失效。密码和浏览器会话始终留在 NocoBase,程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期。POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`;Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 不会出现在工具返回、命令参数、MCP 配置或日志中。
|
||||
|
||||
如果某个 MCP 客户端在登录前完全无法加载自定义连接器,才使用 CLI 备用入口:
|
||||
|
||||
```bash
|
||||
<installed-command> setup --no-browser --json
|
||||
```
|
||||
|
||||
然后由用户手动打开链接。设备授权约十分钟后失效;密码和浏览器会话始终留在 NocoBase,安装程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期,不影响日常 MCP 调用。
|
||||
|
||||
在 Hermes Agent、OpenClaw 或其他远程无界面环境中,Agent 应在可持续读取输出的后台会话运行 `setup --no-browser --json`,取得授权链接和确认码后立即通过当前消息通道发给用户,同时让进程继续轮询。只能转发 HTTPS 授权链接、用户确认码和有效期,绝不能转发 OAuth Token、设备码或凭据文件内容。一个 MCP 进程只能服务一个受信任的用户身份;多用户必须使用相互隔离的 Agent/MCP 实例和凭据路径。
|
||||
|
||||
POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 不会出现在返回 JSON、命令参数、MCP 配置或日志中。不要复制其他用户的凭证文件;每个操作系统用户都必须单独登录。
|
||||
本机桌面终端也可省略 `--no-browser` 以尝试自动打开网页。CLI 会先验证或准备配置,再开始 OAuth,避免用户完成网页授权后才因配置路径不存在而丢失结果。Agent 必须持续读取该进程的输出、把链接和确认码发给用户,并让进程继续轮询。
|
||||
|
||||
只有在 OAuth 暂时不可用、管理员明确要求且用户拥有个人 Authorize Key 时,才使用:
|
||||
|
||||
@@ -132,27 +158,17 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当
|
||||
|
||||
该备用模式必须在用户可直接控制的安全 TTY 中隐藏输入 Key;仍不得让用户把 Key 发给 Agent。
|
||||
|
||||
### 5. 注册并验证 MCP
|
||||
|
||||
读取 setup 返回的 `clientConfig`:
|
||||
|
||||
1. 展示客户端配置路径和新增的 `nocobase-crm` 条目;
|
||||
2. 创建可恢复备份;
|
||||
3. 结构化合并该 server,保留所有其他配置;
|
||||
4. 重载或重启 MCP 客户端;
|
||||
5. 调用 `crm_health`,要求 `ok=true`。
|
||||
|
||||
最终只报告完整 Commit SHA、源码版本、本地构建 tarball 的 SHA-256、安装目录、配置备份位置、认证方式和只读/写入模式,不显示任何 Token 或 Key。
|
||||
|
||||
## 可选写入
|
||||
|
||||
未明确选择时保持只读。只有用户明确要求项目创建/更新,并且管理员确认该用户角色具有对应 NocoBase ACL 时,才在 setup 以及生成的启动参数中加入 `--enable-writes`。这只增加 `create_project` 和 `update_project`;不增加删除、任意集合访问或附件文件上传接口。
|
||||
未明确选择时保持只读。只有用户明确要求项目创建/更新,并且管理员确认该用户角色具有对应 NocoBase ACL 时,才在 `client-config`(或 CLI 备用 `setup`)以及生成的启动参数中加入 `--enable-writes`。这只增加 `create_project` 和 `update_project`;不增加删除、任意集合访问或附件文件上传接口。
|
||||
|
||||
## 故障处理
|
||||
|
||||
- 浏览器未打开:使用 `setup --no-browser`,让用户手动打开终端显示的 `justconn.top` 授权链接。
|
||||
- 授权超时或被取消:重新运行 `setup`,再次核对确认码并授权。
|
||||
- `nocobase_auth_failed`:重新运行 `setup` 完成浏览器登录;不要换用共享高权限 Token 或 Key。
|
||||
- 浏览器未打开:把 `nocobase_login` 返回的 `login_url` 和 `user_code` 发给用户手动打开;不需要重启 MCP。
|
||||
- 授权超时或被取消:再次调用 `nocobase_login` 生成新链接并重新核对确认码。
|
||||
- `nocobase_auth_failed`:调用 `nocobase_login({"force": true})` 重新授权;不要换用共享高权限 Token 或 Key。
|
||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限身份。
|
||||
- Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。
|
||||
- `main` 或 Commit 归档无权访问:报告 Gitea 权限问题,不索取聊天中的访问令牌,也不退回旧 Release。
|
||||
@@ -162,6 +178,6 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当
|
||||
## 用户可发送给 Agent 的最短指令
|
||||
|
||||
```text
|
||||
请按这个 INSTALL.md 帮我完成安装。每次先解析当前 Gitea 仓库 main 分支的最新完整 Commit SHA,并把本次安装固定在该 Commit;不要退回旧 Release。默认只读;请运行 setup,让我只在 NocoBase 官方网页登录并确认授权。不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前必须验证 crm_health:
|
||||
请按这个 INSTALL.md 帮我完成安装。每次先解析当前 Gitea 仓库 main 分支的最新完整 Commit SHA,并把本次安装固定在该 Commit;不要退回旧 Release。默认只读;请先注册 MCP,再调用 nocobase_login,把登录链接和确认码发给我,让我只在 NocoBase 官方网页登录并确认授权。不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前必须验证 crm_health:
|
||||
https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
支持 Windows、macOS 和 Linux。三个平台都会安装 `main` 在任务开始时的最新 Commit;Windows 下载该 Commit 的 `.zip`,macOS/Linux 下载同一 Commit 的 `.tar.gz`。
|
||||
|
||||
不需要提前创建或复制 Authorize Key。安装程序会打开 NocoBase 官方网页;你在网页中登录并确认授权后,本机安装会自动继续。
|
||||
不需要提前创建或复制 Authorize Key。MCP 注册后,Agent 会调用登录工具并把 NocoBase 官方网页链接发给你;桌面环境通常也会自动打开该页面。你在网页中确认授权后,Agent 会自动继续验收。
|
||||
|
||||
### 最快安装方式
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
```text
|
||||
请按这个 INSTALL.md 帮我完成 NocoBase CRM MCP 安装。
|
||||
每次先解析当前 Gitea 仓库 main 分支的最新完整 Commit SHA,并把本次安装固定在该 Commit;不要退回旧 Release。
|
||||
默认只读;请运行 setup,让我只在 NocoBase 官方网页完成登录和授权。
|
||||
默认只读;请先注册 MCP,再调用 nocobase_login,把登录链接和确认码发给我,让我只在 NocoBase 官方网页完成授权。
|
||||
不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前请验证 crm_health 返回 ok=true。
|
||||
|
||||
安装协议:
|
||||
@@ -34,8 +34,8 @@ https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md
|
||||
```
|
||||
|
||||
1. Agent 会读取 Gitea `main` 的最新 Commit,把源码快照、安装协议和构建过程固定到同一个 SHA,再在临时目录完成检查和打包。
|
||||
2. 浏览器打开后,在 `justconn.top` 登录并核对页面与终端显示的确认码,然后点击授权。远程或无界面 Agent 会把授权链接和确认码发到当前对话。
|
||||
3. 安装完成后,确认 Agent 报告完整 Commit SHA,并且 `crm_health` 返回 `ok=true`。
|
||||
2. Agent 会先注册 MCP,再调用 `nocobase_login`。桌面环境通常自动打开浏览器;无界面或远程 Agent 会把同一个授权链接和确认码发到当前对话。
|
||||
3. 你在 `justconn.top` 登录、核对确认码并授权后,Agent 会轮询 `nocobase_login_status`,最后确认 `crm_health` 返回 `ok=true`。
|
||||
|
||||
完整的安装和验收规则见 [INSTALL.md](INSTALL.md)。该 `main` 链接是安装入口;Agent 必须先解析一次最新 Commit,然后重新读取该 Commit 中不可变的同一文件,不能在一次安装中混用不同提交。
|
||||
|
||||
@@ -76,8 +76,10 @@ https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md
|
||||
|
||||
## 遇到问题
|
||||
|
||||
- 登录失效:重新运行安装程序的 `setup`,在浏览器中再次授权;正常情况下访问令牌会在后台自动刷新。
|
||||
- 浏览器没有打开:重新运行 `setup --no-browser`,然后手动打开终端显示的授权链接。
|
||||
- 尚未登录:让 Agent 调用 `nocobase_login`,把返回的 `login_url` 和 `user_code` 发给你;授权后调用 `nocobase_login_status`。
|
||||
- 登录失效:让 Agent 调用 `nocobase_login` 并设置 `force=true`,重新在浏览器授权;正常情况下访问令牌会在后台自动刷新。
|
||||
- 浏览器没有打开:直接打开 `nocobase_login` 返回的 `justconn.top` 链接。即使桌面自动打开失败,该链接仍会返回给 Agent。
|
||||
- MCP 客户端暂时无法调用登录工具:可在本人终端运行安装程序的 `setup --no-browser`,这是备用入口。
|
||||
- 仅在 OAuth 暂时不可用且管理员明确要求时,才使用 `setup --manual-key` 的隐藏输入备用模式;不要把 Key 发到聊天中。
|
||||
- 返回 `NO_PERMISSIONS`:请检查本人 NocoBase 角色和数据权限。
|
||||
- 请求超时:确认可以访问 `https://justconn.top`,或缩小查询范围。
|
||||
|
||||
@@ -11,6 +11,7 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
|
||||
- `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。
|
||||
- `src/nocobase-endpoints.ts` 集中固定 API、OAuth issuer、发现地址和 resource identifier。
|
||||
- `src/oauth.ts` 实现 OAuth 发现、动态公共客户端注册、设备授权、浏览器启动、轮询和刷新;不读取浏览器 Cookie,也不监听本机端口。
|
||||
- `src/auth-service.ts` 把设备授权包装成 MCP 可调用的开始/状态会话;先返回安全的网页链接,再在后台验证和保存授权结果。
|
||||
- `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` 保存纯函数。
|
||||
@@ -23,13 +24,14 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
|
||||
- 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。
|
||||
- OAuth issuer、协议端点和浏览器授权 URL 必须属于固定的 `https://justconn.top` 来源。
|
||||
- Device Authorization 请求 `api offline_access`;密码和浏览器会话不会离开 NocoBase。
|
||||
- MCP 可以在无配置、无凭据状态下准备默认配置并启动;只有业务请求需要身份。`nocobase_login` 返回网页链接和确认码,后台完成的 Token 不经过 Agent。
|
||||
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
|
||||
- Token 会从 HTTP 错误中替换为 `<redacted>`。
|
||||
- stdio 是唯一默认传输,不创建后台服务或监听端口。
|
||||
|
||||
## 兼容规则
|
||||
|
||||
- 保持 17 个只读工具名称、参数和主要响应形状稳定。
|
||||
- 保持 17 个 CRM 只读工具名称、参数和主要响应形状稳定,并固定提供 `nocobase_login`、`nocobase_login_status` 两个身份工具。
|
||||
- 写模式只增加 `create_project`、`update_project`,不提供 delete/destroy。
|
||||
- `include_raw` 默认在 MCP 边界拒绝。
|
||||
- 配置继续接受 camelCase 与 snake_case 字段别名。
|
||||
|
||||
@@ -38,7 +38,7 @@ Gitea 仓库访问受限时,用户或 Agent 必须具有源码和 Commit 归
|
||||
|
||||
至少使用两个权限不同的真实测试账号:
|
||||
|
||||
1. 分别使用两个账号在隔离的 OS 用户/凭证路径运行 setup,并在 NocoBase 网页授权。
|
||||
1. 分别在隔离的 OS 用户/凭证路径注册 MCP,调用 `nocobase_login`,再使用两个账号在 NocoBase 网页授权。
|
||||
2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups`、`list_projects`。
|
||||
3. 确认返回范围与各自在 NocoBase UI 中的 ACL 一致。
|
||||
4. 无项目写权限账号在 `--enable-writes` 下调用 `create_project`,确认 NocoBase 拒绝。
|
||||
@@ -54,8 +54,8 @@ Gitea 仓库访问受限时,用户或 Agent 必须具有源码和 Commit 归
|
||||
|
||||
## 常见故障
|
||||
|
||||
- `nocobase_auth_failed`:重新运行 setup,让用户在 NocoBase 网页重新授权。
|
||||
- 浏览器无法启动:使用 `setup --no-browser` 并手动打开显示的授权链接。
|
||||
- `nocobase_auth_failed`:调用 `nocobase_login({"force": true})`,让用户在 NocoBase 网页重新授权。
|
||||
- 浏览器无法启动:把 `nocobase_login` 返回的链接和确认码交给用户手动打开;CLI 的 `setup --no-browser` 仅作备用。
|
||||
- OAuth 服务暂时不可用:先报告服务状态;只有管理员明确要求时才使用 `setup --manual-key`。
|
||||
- `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。
|
||||
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
|
||||
|
||||
+11
-5
@@ -12,16 +12,18 @@ nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||
```
|
||||
|
||||
- `setup`:通过 NocoBase OAuth 浏览器授权登录、准备 CRM 配置并输出 MCP 客户端配置。
|
||||
- `serve`:通过 stdio 启动 MCP;省略子命令时也是此模式。
|
||||
- `serve`:通过 stdio 启动 MCP;省略子命令时也是此模式。尚未登录时也能启动和列出工具。
|
||||
- `doctor`:验证凭据和 CRM 映射是否可用。
|
||||
- `client-config`:输出可合并到 MCP 客户端的配置片段,不包含任何 Token 或 Key。
|
||||
- `client-config`:准备默认 CRM 配置并输出可合并到 MCP 客户端的配置片段;不要求登录,也不包含任何 Token 或 Key。
|
||||
- `--enable-writes`:显式增加受控项目写入工具;默认不启用。
|
||||
- `--no-browser`:不自动启动浏览器,只显示 NocoBase 授权链接和确认码。
|
||||
- `--manual-key`:仅在 OAuth 不可用时,通过安全 TTY 隐藏输入个人 Authorize Key。
|
||||
|
||||
## 默认只读工具
|
||||
## 默认工具
|
||||
|
||||
默认稳定提供 17 个只读工具:
|
||||
默认提供 2 个登录工具和 17 个 CRM 只读工具。
|
||||
|
||||
- 登录:`nocobase_login`、`nocobase_login_status`
|
||||
|
||||
- 健康、配置和总览:`crm_health`、`crm_schema`、`crm_overview`、`crm_filter_options`
|
||||
- 用户与团队:`resolve_owner`、`list_teams`、`resolve_team`、`get_team`、`team_crm_overview`
|
||||
@@ -31,6 +33,8 @@ nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||
|
||||
查询结果默认返回经过整理的紧凑字段,而不是完整 NocoBase 原始记录。
|
||||
|
||||
`nocobase_login` 会启动用户确认的 OAuth 授权,并在成功后写入本机私有凭据文件,因此不会被标记为只读;它不会修改任何 CRM 业务数据。该工具只返回可交给用户的 `login_url`、`user_code`、有效期和状态,不返回 Token 或设备码。
|
||||
|
||||
## 可选工具
|
||||
|
||||
设置 `NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1` 时,会临时增加 `crm_config_template`,用于生成带字段元数据的配置参考。
|
||||
@@ -65,6 +69,8 @@ OAuth Token 和 Authorize Key 都不支持也不允许通过环境变量传入
|
||||
|
||||
## OAuth 登录
|
||||
|
||||
推荐在 MCP 注册后调用 `nocobase_login`,把返回的链接和确认码发给用户,再用 `nocobase_login_status` 查询结果。桌面环境会尝试自动打开浏览器;远程或无界面环境使用相同链接,不需要回调端口。已有凭据被 NocoBase 拒绝时,使用 `nocobase_login({"force": true})` 重新授权。CLI 的 `setup --no-browser` 保留为客户端无法在登录前加载连接器时的备用入口。
|
||||
|
||||
- 发现地址:`https://justconn.top/api/.well-known/openid-configuration`
|
||||
- 授权模式:OAuth 2.1 Device Authorization
|
||||
- 客户端类型:动态注册的 native public client,不保存 client secret
|
||||
@@ -79,7 +85,7 @@ OAuth Token 和 Authorize Key 都不支持也不允许通过环境变量传入
|
||||
AI 客户端 → 本机 stdio MCP → HTTPS NocoBase API → 当前用户 ACL
|
||||
```
|
||||
|
||||
- 启动后凭据缓存在进程内存中;日常请求不重复读取文件,也不增加 OAuth 网络请求。
|
||||
- 启动后凭据缓存在进程内存中;MCP 登录成功时会主动刷新这份缓存,不需要重启连接器。
|
||||
- 只有访问令牌临近过期时才刷新,并把轮换后的凭据写回私有文件。
|
||||
- 默认不创建后台服务,不监听本机或公网端口。
|
||||
- NocoBase ACL、字段权限和行级权限是最终权限边界。
|
||||
|
||||
@@ -60,7 +60,10 @@
|
||||
"entrypoints": {
|
||||
"guide": "INSTALL.md",
|
||||
"binary": "nocobase-crm-mcp",
|
||||
"guidedCommand": "setup",
|
||||
"guidedCommand": "client-config",
|
||||
"mcpLoginTool": "nocobase_login",
|
||||
"mcpLoginStatusTool": "nocobase_login_status",
|
||||
"cliLoginFallback": "setup --no-browser",
|
||||
"configTemplate": "config.example.json"
|
||||
},
|
||||
"securityDefaults": {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
credentialsPath,
|
||||
loadStoredCredentials,
|
||||
NOCOBASE_API_BASE_URL,
|
||||
saveOAuthCredentials,
|
||||
} from "./credentials.js";
|
||||
import { NocoBaseClient } from "./nocobase-client.js";
|
||||
import {
|
||||
type BrowserLoginOptions,
|
||||
type BrowserLoginSession,
|
||||
type OAuthCredentials,
|
||||
startBrowserLogin,
|
||||
} from "./oauth.js";
|
||||
|
||||
export type AuthPayload = Record<string, unknown>;
|
||||
|
||||
export interface AuthService {
|
||||
start(options?: { force?: boolean }): Promise<AuthPayload>;
|
||||
status(): Promise<AuthPayload>;
|
||||
}
|
||||
|
||||
export interface NocoBaseAuthServiceOptions {
|
||||
credentialsFile?: string;
|
||||
now?: () => number;
|
||||
openBrowser?: BrowserLoginOptions["openBrowser"];
|
||||
startLogin?: (options: BrowserLoginOptions) => Promise<BrowserLoginSession>;
|
||||
verifyCredentials?: (credentials: OAuthCredentials) => Promise<void>;
|
||||
onCredentialsSaved?: () => void;
|
||||
}
|
||||
|
||||
interface PendingAuthorization {
|
||||
kind: "pending";
|
||||
prompt: BrowserLoginSession["prompt"];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface FailedAuthorization {
|
||||
kind: "failed";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AuthenticatedAuthorization {
|
||||
kind: "authenticated";
|
||||
}
|
||||
|
||||
type AuthorizationState = PendingAuthorization | FailedAuthorization | AuthenticatedAuthorization;
|
||||
|
||||
export class NocoBaseAuthService implements AuthService {
|
||||
private readonly credentialsFile: string;
|
||||
private readonly now: () => number;
|
||||
private readonly openBrowser?: BrowserLoginOptions["openBrowser"];
|
||||
private readonly startLogin: (options: BrowserLoginOptions) => Promise<BrowserLoginSession>;
|
||||
private readonly verifyCredentials: (credentials: OAuthCredentials) => Promise<void>;
|
||||
private readonly onCredentialsSaved?: () => void;
|
||||
private state?: AuthorizationState;
|
||||
private startInFlight?: Promise<AuthPayload>;
|
||||
|
||||
constructor(options: NocoBaseAuthServiceOptions = {}) {
|
||||
this.credentialsFile = options.credentialsFile ?? credentialsPath();
|
||||
this.now = options.now ?? Date.now;
|
||||
this.openBrowser = options.openBrowser;
|
||||
this.startLogin = options.startLogin ?? startBrowserLogin;
|
||||
this.verifyCredentials = options.verifyCredentials ?? verifyOAuthCredentials;
|
||||
this.onCredentialsSaved = options.onCredentialsSaved;
|
||||
}
|
||||
|
||||
async start(options: { force?: boolean } = {}): Promise<AuthPayload> {
|
||||
if (this.startInFlight) return await this.startInFlight;
|
||||
const pending = this.startInternal(options.force ?? false);
|
||||
this.startInFlight = pending;
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
if (this.startInFlight === pending) this.startInFlight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async status(): Promise<AuthPayload> {
|
||||
if (this.state?.kind === "pending") return this.pendingPayload(this.state);
|
||||
if (this.state?.kind === "failed") {
|
||||
return {
|
||||
status: "authorization_failed",
|
||||
error: {
|
||||
code: "nocobase_authorization_failed",
|
||||
message: this.state.message,
|
||||
retryable: true,
|
||||
},
|
||||
next_action: "Call nocobase_login again to create a new browser authorization link.",
|
||||
};
|
||||
}
|
||||
if (this.state?.kind === "authenticated" || this.storedOAuthClientId() !== undefined) {
|
||||
return authenticatedPayload();
|
||||
}
|
||||
if (this.hasStoredPersonalKey()) return authenticatedPayload();
|
||||
return {
|
||||
status: "login_required",
|
||||
next_action:
|
||||
"Call nocobase_login, send its login_url and user_code to the user, then poll nocobase_login_status.",
|
||||
};
|
||||
}
|
||||
|
||||
private async startInternal(force: boolean): Promise<AuthPayload> {
|
||||
if (this.state?.kind === "pending") return this.pendingPayload(this.state);
|
||||
if (!force) {
|
||||
const current = await this.status();
|
||||
if (current.status === "authenticated") return current;
|
||||
}
|
||||
|
||||
const session = await this.startLogin({
|
||||
clientId: this.storedOAuthClientId(),
|
||||
openBrowser: this.openBrowser,
|
||||
sleep: backgroundSleep,
|
||||
});
|
||||
const pending: PendingAuthorization = {
|
||||
kind: "pending",
|
||||
prompt: session.prompt,
|
||||
expiresAt: this.now() + session.prompt.expiresIn * 1_000,
|
||||
};
|
||||
this.state = pending;
|
||||
void session.credentials
|
||||
.then(async (credentials) => {
|
||||
await this.verifyCredentials(credentials);
|
||||
saveOAuthCredentials(credentials, this.credentialsFile);
|
||||
this.onCredentialsSaved?.();
|
||||
if (this.state === pending) this.state = { kind: "authenticated" };
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.state === pending) {
|
||||
this.state = {
|
||||
kind: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
return this.pendingPayload(pending);
|
||||
}
|
||||
|
||||
private pendingPayload(state: PendingAuthorization): AuthPayload {
|
||||
return {
|
||||
status: "authorization_pending",
|
||||
login_url: state.prompt.url,
|
||||
user_code: state.prompt.userCode,
|
||||
expires_in_seconds: Math.max(0, Math.ceil((state.expiresAt - this.now()) / 1_000)),
|
||||
browser_opened: state.prompt.opened,
|
||||
next_action:
|
||||
"Send login_url and user_code to the user. After approval, poll nocobase_login_status until authenticated.",
|
||||
};
|
||||
}
|
||||
|
||||
private storedOAuthClientId(): string | undefined {
|
||||
if (!existsSync(this.credentialsFile)) return undefined;
|
||||
try {
|
||||
const stored = loadStoredCredentials(this.credentialsFile);
|
||||
return stored.type === "oauth" ? stored.clientId : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private hasStoredPersonalKey(): boolean {
|
||||
if (!existsSync(this.credentialsFile)) return false;
|
||||
try {
|
||||
return loadStoredCredentials(this.credentialsFile).type === "personal-authorize-key";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyOAuthCredentials(credentials: OAuthCredentials): Promise<void> {
|
||||
const client = new NocoBaseClient(() => ({
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey: credentials.accessToken,
|
||||
}));
|
||||
await client.verifyCredentials();
|
||||
}
|
||||
|
||||
async function backgroundSleep(milliseconds: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
function authenticatedPayload(): AuthPayload {
|
||||
return {
|
||||
status: "authenticated",
|
||||
next_action: "Call crm_health, then continue with the requested CRM operation.",
|
||||
};
|
||||
}
|
||||
+14
-13
@@ -2,6 +2,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { NocoBaseAuthService } from "./auth-service.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import {
|
||||
createCredentialProvider,
|
||||
@@ -42,7 +43,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||
await setup(args);
|
||||
return;
|
||||
}
|
||||
const configPath = resolveConfigPath(args.config);
|
||||
const configPath = ensureConfig(args.config);
|
||||
if (args.command === "client-config") {
|
||||
printResult(clientConfig(configPath, args.enableWrites), args.json);
|
||||
return;
|
||||
@@ -55,6 +56,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const server = createServer({
|
||||
authService: new NocoBaseAuthService({ onCredentialsSaved: runtime.reloadCredentials }),
|
||||
crmService: runtime.crm,
|
||||
projectService: runtime.projects,
|
||||
readonly: !args.enableWrites,
|
||||
@@ -73,19 +75,23 @@ export function createRuntime(configPath: string): {
|
||||
client: NocoBaseClient;
|
||||
crm: CrmService;
|
||||
projects: ProjectService;
|
||||
reloadCredentials: () => void;
|
||||
} {
|
||||
const config = loadConfig(configPath);
|
||||
const client = new NocoBaseClient(createCredentialProvider(), { dataSource: config.dataSource });
|
||||
const credentialProvider = createCredentialProvider();
|
||||
const client = new NocoBaseClient(credentialProvider, { dataSource: config.dataSource });
|
||||
return {
|
||||
client,
|
||||
crm: new CrmService(config, client),
|
||||
projects: new ProjectService(config.project, client),
|
||||
reloadCredentials: credentialProvider.reset,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(args: ParsedArgs): Promise<void> {
|
||||
let authorizeKey: string;
|
||||
let authentication: "oauth-browser" | "personal-authorize-key-fallback";
|
||||
let configPath: string;
|
||||
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");
|
||||
@@ -95,10 +101,12 @@ async function setup(args: ParsedArgs): Promise<void> {
|
||||
"Enter a revocable personal Authorize Key only in the hidden prompt below.\n" +
|
||||
"Do not paste the Key into Agent chat.\n",
|
||||
);
|
||||
configPath = ensureConfig(args.config);
|
||||
authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
|
||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
|
||||
authentication = "personal-authorize-key-fallback";
|
||||
} else {
|
||||
configPath = ensureConfig(args.config);
|
||||
process.stderr.write(
|
||||
`Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` +
|
||||
"Your password and browser session stay inside NocoBase.\n",
|
||||
@@ -121,7 +129,6 @@ async function setup(args: ParsedArgs): Promise<void> {
|
||||
authorizeKey,
|
||||
}));
|
||||
await verificationClient.verifyCredentials();
|
||||
const configPath = ensureConfig(args.config);
|
||||
saveOAuthCredentials(oauthCredentials);
|
||||
await printSetupResult(args, configPath, authentication);
|
||||
return;
|
||||
@@ -131,7 +138,6 @@ async function setup(args: ParsedArgs): Promise<void> {
|
||||
authorizeKey,
|
||||
}));
|
||||
await verificationClient.verifyCredentials();
|
||||
const configPath = ensureConfig(args.config);
|
||||
saveCredentials({ authorizeKey });
|
||||
await printSetupResult(args, configPath, authentication);
|
||||
}
|
||||
@@ -170,8 +176,9 @@ function reusableOAuthClientId(): string | undefined {
|
||||
}
|
||||
|
||||
function ensureConfig(explicit?: string): string {
|
||||
if (explicit) {
|
||||
const path = resolve(explicit);
|
||||
const configured = explicit ?? process.env.NOCOBASE_MCP_CONFIG?.trim();
|
||||
if (configured) {
|
||||
const path = resolve(configured);
|
||||
if (!existsSync(path)) throw new Error(`Config file does not exist: ${path}`);
|
||||
return path;
|
||||
}
|
||||
@@ -185,13 +192,6 @@ function ensureConfig(explicit?: string): string {
|
||||
return destination;
|
||||
}
|
||||
|
||||
function resolveConfigPath(explicit?: string): string {
|
||||
const path = explicit ?? process.env.NOCOBASE_MCP_CONFIG ?? join(dirname(credentialsPath()), "config.json");
|
||||
const resolved = resolve(path);
|
||||
if (!existsSync(resolved)) throw new Error(`CRM config is missing: ${resolved}. Run setup first.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function clientConfig(configPath: string, enableWrites = false): Record<string, unknown> {
|
||||
const entrypoint = resolve(process.argv[1] ?? fileURLToPath(import.meta.url));
|
||||
return {
|
||||
@@ -263,6 +263,7 @@ The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}.
|
||||
The setup command signs in through NocoBase OAuth in the user's browser.
|
||||
Use --no-browser to print the authorization URL without opening it.
|
||||
Use --manual-key only as a hidden local TTY fallback.
|
||||
The stdio server can start before login; call nocobase_login to obtain a user-facing browser URL.
|
||||
The default stdio server is read-only.
|
||||
`;
|
||||
}
|
||||
|
||||
+40
-19
@@ -27,6 +27,11 @@ export interface CredentialProviderOptions {
|
||||
refreshSkewMs?: number;
|
||||
}
|
||||
|
||||
export interface CredentialProvider {
|
||||
(): Promise<Credentials>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function credentialsPath(): string {
|
||||
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
|
||||
if (override) return resolve(override);
|
||||
@@ -70,20 +75,18 @@ export function loadStoredCredentials(path = credentialsPath()): StoredCredentia
|
||||
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)) throw new Error("Credential file is invalid; run setup again");
|
||||
if (!isRecord(parsed)) throw new Error("Credential file is invalid; sign in again");
|
||||
if (parsed.type === "oauth") {
|
||||
const oauth = parsed as unknown as OAuthCredentials;
|
||||
validateOAuthCredentials(oauth);
|
||||
return { ...oauth };
|
||||
}
|
||||
const 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; sign in again");
|
||||
return { type: "personal-authorize-key", authorizeKey };
|
||||
}
|
||||
|
||||
export function createCredentialProvider(
|
||||
options: CredentialProviderOptions = {},
|
||||
): () => Promise<Credentials> {
|
||||
export function createCredentialProvider(options: CredentialProviderOptions = {}): CredentialProvider {
|
||||
const path = options.path ?? credentialsPath();
|
||||
const now = options.now ?? Date.now;
|
||||
const refresh = options.refresh ?? ((credentials) => refreshOAuthCredentials(credentials));
|
||||
@@ -92,8 +95,10 @@ export function createCredentialProvider(
|
||||
let stored: StoredCredentials | undefined;
|
||||
let refreshInFlight: Promise<OAuthCredentials> | undefined;
|
||||
let refreshRetryAt = 0;
|
||||
let generation = 0;
|
||||
|
||||
return async (): Promise<Credentials> => {
|
||||
const provider = async (): Promise<Credentials> => {
|
||||
const requestGeneration = generation;
|
||||
stored ??= loadStoredCredentials(path);
|
||||
if (stored.type === "personal-authorize-key") {
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.authorizeKey };
|
||||
@@ -106,29 +111,45 @@ export function createCredentialProvider(
|
||||
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;
|
||||
});
|
||||
if (!refreshInFlight) {
|
||||
const refreshGeneration = generation;
|
||||
let pending: Promise<OAuthCredentials>;
|
||||
pending = refresh(expiringCredentials)
|
||||
.then((refreshed) => {
|
||||
validateOAuthCredentials(refreshed);
|
||||
if (refreshGeneration === generation) {
|
||||
saveOAuthCredentials(refreshed, path);
|
||||
stored = refreshed;
|
||||
refreshRetryAt = 0;
|
||||
}
|
||||
return refreshed;
|
||||
})
|
||||
.finally(() => {
|
||||
if (refreshInFlight === pending) refreshInFlight = undefined;
|
||||
});
|
||||
refreshInFlight = pending;
|
||||
}
|
||||
try {
|
||||
const refreshed = await refreshInFlight;
|
||||
if (requestGeneration !== generation) return await provider();
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: refreshed.accessToken };
|
||||
} catch (error) {
|
||||
if (requestGeneration !== generation) return await provider();
|
||||
if (expiringCredentials.expiresAt > now()) {
|
||||
refreshRetryAt = now() + refreshRetryDelayMs;
|
||||
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: expiringCredentials.accessToken };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`OAuth session could not be refreshed; run setup again: ${message}`);
|
||||
throw new Error(`OAuth session could not be refreshed; sign in again: ${message}`);
|
||||
}
|
||||
};
|
||||
provider.reset = (): void => {
|
||||
generation += 1;
|
||||
stored = undefined;
|
||||
refreshInFlight = undefined;
|
||||
refreshRetryAt = 0;
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function readHidden(prompt: string): Promise<string> {
|
||||
@@ -184,7 +205,7 @@ function validateOAuthCredentials(credentials: OAuthCredentials): void {
|
||||
!Number.isFinite(credentials.expiresAt) ||
|
||||
credentials.expiresAt <= 0
|
||||
) {
|
||||
throw new Error("OAuth credential file is invalid; run setup again");
|
||||
throw new Error("OAuth credential file is invalid; sign in again");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -421,6 +421,12 @@ export class CrmService {
|
||||
const customerFilter = buildAndFilter([
|
||||
buildRelationIdCondition(this.config.customer.ownerField, ownerId),
|
||||
]);
|
||||
const contactFilter = buildAndFilter([
|
||||
buildRelationIdCondition(
|
||||
[this.config.contact.customerField, this.config.customer.ownerField, "id"],
|
||||
ownerId,
|
||||
),
|
||||
]);
|
||||
const followupFilter = buildAndFilter([
|
||||
buildRelationIdCondition(this.config.followup.ownerField, ownerId),
|
||||
buildDateRangeCondition(this.config.followup.dateField, options.dateFrom, options.dateTo),
|
||||
@@ -428,7 +434,7 @@ export class CrmService {
|
||||
const [customers, contacts, followups, customerStatus, customerSource, customerOwner, recent] =
|
||||
await Promise.all([
|
||||
this.count(this.config.customer.resource, this.config.customer.idField, customerFilter),
|
||||
this.count(this.config.contact.resource, this.config.contact.idField, {}),
|
||||
this.count(this.config.contact.resource, this.config.contact.idField, contactFilter),
|
||||
this.count(this.config.followup.resource, this.config.followup.idField, followupFilter),
|
||||
this.groupCount(
|
||||
this.config.customer.resource,
|
||||
|
||||
+22
-8
@@ -53,6 +53,11 @@ export interface BrowserLoginOptions {
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface BrowserLoginSession {
|
||||
prompt: AuthorizationPrompt;
|
||||
credentials: Promise<OAuthCredentials>;
|
||||
}
|
||||
|
||||
export interface RefreshOptions {
|
||||
fetch?: typeof fetch;
|
||||
now?: () => number;
|
||||
@@ -69,6 +74,11 @@ export class OAuthProtocolError extends Error {
|
||||
}
|
||||
|
||||
export async function loginWithBrowser(options: BrowserLoginOptions = {}): Promise<OAuthCredentials> {
|
||||
const session = await startBrowserLogin(options);
|
||||
return await session.credentials;
|
||||
}
|
||||
|
||||
export async function startBrowserLogin(options: BrowserLoginOptions = {}): Promise<BrowserLoginSession> {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const now = options.now ?? Date.now;
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
@@ -91,18 +101,22 @@ export async function loginWithBrowser(options: BrowserLoginOptions = {}): Promi
|
||||
authorization.verificationUriComplete ?? authorization.verificationUri,
|
||||
).href;
|
||||
const opened = await (options.openBrowser ?? openExternalUrl)(browserUrl);
|
||||
options.onAuthorization?.({
|
||||
const prompt = {
|
||||
opened,
|
||||
url: browserUrl,
|
||||
userCode: authorization.userCode,
|
||||
expiresIn: authorization.expiresIn,
|
||||
});
|
||||
};
|
||||
options.onAuthorization?.(prompt);
|
||||
|
||||
return await pollForTokens(discovery, clientId, authorization, {
|
||||
fetch: fetchImpl,
|
||||
now,
|
||||
sleep,
|
||||
});
|
||||
return {
|
||||
prompt,
|
||||
credentials: pollForTokens(discovery, clientId, authorization, {
|
||||
fetch: fetchImpl,
|
||||
now,
|
||||
sleep,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshOAuthCredentials(
|
||||
@@ -280,7 +294,7 @@ async function pollForTokens(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new OAuthProtocolError("NocoBase browser authorization expired; run setup again", "expired_token");
|
||||
throw new OAuthProtocolError("NocoBase browser authorization expired; start a new login", "expired_token");
|
||||
}
|
||||
|
||||
function tokenCredentials(
|
||||
|
||||
+32
-3
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
import type { AuthService } from "./auth-service.js";
|
||||
import type { CrmService } from "./crm-service.js";
|
||||
import { NocoBaseError } from "./nocobase-client.js";
|
||||
import type { ProjectService } from "./project-service.js";
|
||||
@@ -16,13 +17,37 @@ const pagination = {
|
||||
};
|
||||
|
||||
export function createServer(options: {
|
||||
authService: AuthService;
|
||||
crmService: CrmService;
|
||||
projectService?: ProjectService;
|
||||
readonly?: boolean;
|
||||
enableConfigTools?: boolean;
|
||||
}): McpServer {
|
||||
const server = new McpServer({ name: "nocobase-crm", version: VERSION });
|
||||
const auth = options.authService;
|
||||
const crm = options.crmService;
|
||||
register(
|
||||
server,
|
||||
"nocobase_login",
|
||||
"Start NocoBase browser sign-in. Returns a safe login URL and confirmation code to send to the user; never returns OAuth tokens.",
|
||||
{
|
||||
force: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Start a fresh login even when stored credentials exist; use after crm_health rejects them.",
|
||||
),
|
||||
},
|
||||
({ force }) => auth.start({ force }),
|
||||
writeAnnotations,
|
||||
);
|
||||
register(
|
||||
server,
|
||||
"nocobase_login_status",
|
||||
"Check browser sign-in status. While pending, repeat the safe login URL; after user approval, returns authenticated.",
|
||||
{},
|
||||
() => auth.status(),
|
||||
);
|
||||
register(
|
||||
server,
|
||||
"crm_health",
|
||||
@@ -44,7 +69,10 @@ export function createServer(options: {
|
||||
{
|
||||
date_from: z.string().optional().describe("Optional lower follow-up date bound."),
|
||||
date_to: z.string().optional().describe("Optional upper follow-up date bound."),
|
||||
owner: z.string().optional().describe("Optional user ownership and participation filter."),
|
||||
owner: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional user filter: owned customers, their contacts, and participated follow-ups."),
|
||||
},
|
||||
({ date_from, date_to, owner }) => crm.crmOverview({ dateFrom: date_from, dateTo: date_to, owner }),
|
||||
);
|
||||
@@ -381,8 +409,9 @@ function register(
|
||||
description: string,
|
||||
inputSchema: z.ZodRawShape,
|
||||
operation: (args: any) => Promise<ToolPayload>,
|
||||
annotations = readAnnotations,
|
||||
): void {
|
||||
server.registerTool(name, { description, inputSchema, annotations: readAnnotations }, async (args: any) =>
|
||||
server.registerTool(name, { description, inputSchema, annotations }, async (args: any) =>
|
||||
toolResult(await safeTool(() => operation(args))),
|
||||
);
|
||||
}
|
||||
@@ -413,7 +442,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 complete NocoBase sign-in in the browser.",
|
||||
hint: "Call nocobase_login (force=true if stored credentials were rejected), send its login_url and user_code to the user, then poll nocobase_login_status.",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { NocoBaseAuthService } from "../src/auth-service.js";
|
||||
import { loadStoredCredentials, saveCredentials } from "../src/credentials.js";
|
||||
import type { OAuthCredentials } from "../src/oauth.js";
|
||||
|
||||
const oauthCredentials: OAuthCredentials = {
|
||||
type: "oauth",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
accessToken: "private-access-token",
|
||||
refreshToken: "private-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expiresAt: 901_000,
|
||||
};
|
||||
|
||||
test("returns a browser link immediately, then verifies and saves OAuth credentials in the background", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-service-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
let resolveCredentials: (credentials: OAuthCredentials) => void = () => undefined;
|
||||
const credentials = new Promise<OAuthCredentials>((resolve) => {
|
||||
resolveCredentials = resolve;
|
||||
});
|
||||
let verified = 0;
|
||||
let reloaded = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
startLogin: async () => ({
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
userCode: "ABCD-EFGH",
|
||||
expiresIn: 600,
|
||||
},
|
||||
credentials,
|
||||
}),
|
||||
verifyCredentials: async (value) => {
|
||||
assert.equal(value.accessToken, oauthCredentials.accessToken);
|
||||
verified += 1;
|
||||
},
|
||||
onCredentialsSaved: () => {
|
||||
reloaded += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const started = await service.start();
|
||||
assert.deepEqual(started, {
|
||||
status: "authorization_pending",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
user_code: "ABCD-EFGH",
|
||||
expires_in_seconds: 600,
|
||||
browser_opened: false,
|
||||
next_action:
|
||||
"Send login_url and user_code to the user. After approval, poll nocobase_login_status until authenticated.",
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(started), /private-access-token|private-refresh-token/);
|
||||
assert.equal(existsSync(path), false);
|
||||
assert.equal((await service.status()).status, "authorization_pending");
|
||||
|
||||
resolveCredentials(oauthCredentials);
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(path); attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
assert.equal(verified, 1);
|
||||
assert.equal(reloaded, 1);
|
||||
assert.equal((await service.status()).status, "authenticated");
|
||||
const stored = loadStoredCredentials(path);
|
||||
assert.equal(stored.type, "oauth");
|
||||
if (process.platform !== "win32") assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test("reuses stored login unless a fresh authorization is explicitly requested", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-force-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveCredentials({ authorizeKey: "personal-key" }, path);
|
||||
let starts = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
startLogin: async () => {
|
||||
starts += 1;
|
||||
return {
|
||||
prompt: {
|
||||
opened: true,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=FRESH-CODE",
|
||||
userCode: "FRESH-CODE",
|
||||
expiresIn: 600,
|
||||
},
|
||||
credentials: new Promise<OAuthCredentials>(() => undefined),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authenticated");
|
||||
assert.equal(starts, 0);
|
||||
assert.equal((await service.start({ force: true })).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
assert.equal((await service.start({ force: true })).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
@@ -19,6 +19,9 @@ test("package, manifest, and runtime versions agree with commit-pinned installat
|
||||
assert.equal(manifest.distribution.releaseRef, undefined);
|
||||
assert.equal(manifest.installation.temporarySourceArchiveBuild, true);
|
||||
assert.equal(manifest.installation.fallbackToRelease, false);
|
||||
assert.equal(manifest.entrypoints.guidedCommand, "client-config");
|
||||
assert.equal(manifest.entrypoints.mcpLoginTool, "nocobase_login");
|
||||
assert.equal(manifest.entrypoints.mcpLoginStatusTool, "nocobase_login_status");
|
||||
assert.deepEqual(packageJson.dependencies, undefined);
|
||||
assert.equal(manifest.runtime.runtimeNpmDependencies, 0);
|
||||
});
|
||||
@@ -34,8 +37,11 @@ test("installation protocol resolves main once and never falls back to an old Re
|
||||
assert.match(guide, /archive\/<commit-sha>\.tar\.gz/);
|
||||
assert.match(guide, /Windows 使用 `.zip`/);
|
||||
assert.match(guide, /不得.*退回旧 Release/);
|
||||
assert.match(guide, /client-config --json/);
|
||||
assert.match(guide, /nocobase_login\(\{\}\)/);
|
||||
assert.ok(guide.includes(installUrl));
|
||||
assert.ok(readme.includes(installUrl));
|
||||
assert.match(readme, /nocobase_login/);
|
||||
assert.doesNotMatch(guide, /下载本文件所在的同一固定 Release/);
|
||||
assert.doesNotMatch(readme, /<main 分支的 INSTALL\.md 链接>/);
|
||||
});
|
||||
@@ -64,6 +70,52 @@ test("manual Key fallback refuses a non-TTY before creating credential files", a
|
||||
}
|
||||
});
|
||||
|
||||
test("setup rejects a missing explicit config before starting browser authorization", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-setup-preflight-"));
|
||||
const credentials = join(directory, "credentials.json");
|
||||
const missingConfig = join(directory, "missing-config.json");
|
||||
const previousCredentials = process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
process.env.NOCOBASE_MCP_CREDENTIALS = credentials;
|
||||
try {
|
||||
await assert.rejects(
|
||||
runCli(["setup", "--config", missingConfig, "--no-browser", "--json"]),
|
||||
/Config file does not exist/,
|
||||
);
|
||||
assert.equal(existsSync(credentials), false);
|
||||
} finally {
|
||||
if (previousCredentials === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
else process.env.NOCOBASE_MCP_CREDENTIALS = previousCredentials;
|
||||
}
|
||||
});
|
||||
|
||||
test("client-config prepares the bundled default config before login", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-client-config-"));
|
||||
const credentials = join(directory, "credentials.json");
|
||||
const config = join(directory, "config.json");
|
||||
const previousCredentials = process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
const previousConfig = process.env.NOCOBASE_MCP_CONFIG;
|
||||
const originalWrite = process.stdout.write;
|
||||
let output = "";
|
||||
process.env.NOCOBASE_MCP_CREDENTIALS = credentials;
|
||||
delete process.env.NOCOBASE_MCP_CONFIG;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await runCli(["client-config", "--json"]);
|
||||
assert.equal(existsSync(config), true);
|
||||
assert.equal(existsSync(credentials), false);
|
||||
assert.equal(JSON.parse(output).mcpServers["nocobase-crm"].args.includes("--config"), true);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
if (previousCredentials === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS;
|
||||
else process.env.NOCOBASE_MCP_CREDENTIALS = previousCredentials;
|
||||
if (previousConfig === undefined) delete process.env.NOCOBASE_MCP_CONFIG;
|
||||
else process.env.NOCOBASE_MCP_CONFIG = previousConfig;
|
||||
}
|
||||
});
|
||||
|
||||
test("setup rejects API endpoint overrides", async () => {
|
||||
await assert.rejects(
|
||||
runCli(["setup", "--api-base-url", "https://other.example"]),
|
||||
|
||||
@@ -73,6 +73,19 @@ test("stores OAuth credentials privately and refreshes them once for concurrent
|
||||
if (process.platform !== "win32") assert.equal(statMode(path), 0o600);
|
||||
});
|
||||
|
||||
test("credential provider can reload credentials saved by browser login without restarting MCP", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credential-reload-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveCredentials({ authorizeKey: "old-key" }, path);
|
||||
const provider = createCredentialProvider({ path });
|
||||
assert.equal((await provider()).authorizeKey, "old-key");
|
||||
|
||||
saveCredentials({ authorizeKey: "new-key" }, path);
|
||||
assert.equal((await provider()).authorizeKey, "old-key");
|
||||
provider.reset();
|
||||
assert.equal((await provider()).authorizeKey, "new-key");
|
||||
});
|
||||
|
||||
test("backs off after a refresh failure while the current access token remains valid", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-oauth-backoff-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
|
||||
@@ -102,6 +102,38 @@ test("resolves owner candidates and returns ambiguity as structured data", async
|
||||
assert.equal((listed.error as Record<string, unknown>).code, "owner_ambiguous");
|
||||
});
|
||||
|
||||
test("owner-filtered CRM overview counts only contacts linked to that owner's customers", async () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
const queries: Array<{ resource: string; options: Record<string, unknown> }> = [];
|
||||
const fake = {
|
||||
listRecords: async (resource: string) => {
|
||||
if (resource === config.user.resource) {
|
||||
return { data: [{ id: 10, username: "alice", nickname: "Alice" }] };
|
||||
}
|
||||
return { data: [], meta: { count: 0 } };
|
||||
},
|
||||
queryRecords: async (resource: string, options: Record<string, unknown>) => {
|
||||
queries.push({ resource, options });
|
||||
return Array.isArray(options.dimensions) ? { data: [] } : { data: [{ count: 1 }] };
|
||||
},
|
||||
} as unknown as NocoBaseClient;
|
||||
|
||||
const result = await new CrmService(config, fake).crmOverview({ owner: "alice" });
|
||||
assert.deepEqual(result.totals, { customers: 1, contacts: 1, followups: 1 });
|
||||
const contactCount = queries.find(
|
||||
({ resource, options }) => resource === config.contact.resource && Array.isArray(options.measures),
|
||||
);
|
||||
assert.deepEqual(contactCount?.options.filter, {
|
||||
$and: [
|
||||
{
|
||||
[config.contact.customerField]: {
|
||||
[config.customer.ownerField]: { id: { $eq: 10 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("health never returns the Authorize Key", async () => {
|
||||
const fake = {
|
||||
health: async () => ({ ok: true, endpoint: "https://nb.example", status: "authenticated" }),
|
||||
|
||||
+60
-3
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import type { AuthService } from "../src/auth-service.js";
|
||||
import type { CrmService } from "../src/crm-service.js";
|
||||
import { NocoBaseError } from "../src/nocobase-client.js";
|
||||
import type { ProjectService } from "../src/project-service.js";
|
||||
import { createServer } from "../src/tools.js";
|
||||
|
||||
@@ -17,12 +19,27 @@ function fakeService(): CrmService {
|
||||
) as CrmService;
|
||||
}
|
||||
|
||||
async function connected(readonly: boolean): Promise<{
|
||||
function fakeAuthService(): AuthService {
|
||||
return {
|
||||
start: async () => ({
|
||||
status: "authorization_pending",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
user_code: "TEST-CODE",
|
||||
}),
|
||||
status: async () => ({ status: "login_required" }),
|
||||
};
|
||||
}
|
||||
|
||||
async function connected(
|
||||
readonly: boolean,
|
||||
crmService: CrmService = fakeService(),
|
||||
): Promise<{
|
||||
client: Client;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const server = createServer({
|
||||
crmService: fakeService(),
|
||||
authService: fakeAuthService(),
|
||||
crmService,
|
||||
projectService: fakeService() as unknown as ProjectService,
|
||||
readonly,
|
||||
});
|
||||
@@ -35,13 +52,15 @@ async function connected(readonly: boolean): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
test("read-only server exposes the stable 17-tool contract", async () => {
|
||||
test("read-only server exposes CRM tools plus browser login tools", async () => {
|
||||
const connection = await connected(true);
|
||||
try {
|
||||
const result = await connection.client.listTools();
|
||||
assert.deepEqual(
|
||||
new Set(result.tools.map((tool) => tool.name)),
|
||||
new Set([
|
||||
"nocobase_login",
|
||||
"nocobase_login_status",
|
||||
"crm_health",
|
||||
"crm_schema",
|
||||
"crm_overview",
|
||||
@@ -66,6 +85,44 @@ test("read-only server exposes the stable 17-tool contract", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("authentication failures tell the Agent to use the MCP browser login flow", async () => {
|
||||
const service = {
|
||||
crmHealth: async () => {
|
||||
throw new NocoBaseError(401, "NocoBase credentials are unavailable", {
|
||||
authenticationFailed: true,
|
||||
});
|
||||
},
|
||||
} as unknown as CrmService;
|
||||
const connection = await connected(true, service);
|
||||
try {
|
||||
const result = await connection.client.callTool({ name: "crm_health", arguments: {} });
|
||||
const structured = result.structuredContent as { error: Record<string, unknown> };
|
||||
assert.equal(structured.error.code, "nocobase_auth_failed");
|
||||
assert.match(String(structured.error.hint), /nocobase_login/);
|
||||
assert.match(String(structured.error.hint), /nocobase_login_status/);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("browser login tool returns only the safe user-facing authorization fields", async () => {
|
||||
const connection = await connected(true);
|
||||
try {
|
||||
const result = await connection.client.callTool({
|
||||
name: "nocobase_login",
|
||||
arguments: {},
|
||||
});
|
||||
assert.deepEqual(result.structuredContent, {
|
||||
status: "authorization_pending",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
user_code: "TEST-CODE",
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(result), /access.token|refresh.token|device.code/i);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("write mode adds only fixed project create and update tools", async () => {
|
||||
const connection = await connected(false);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user