feat: add MCP-guided browser login

This commit is contained in:
nac
2026-08-20 04:09:07 +00:00
parent c7e62d1d6d
commit 7cc521938f
17 changed files with 638 additions and 88 deletions
+41 -25
View File
@@ -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
```
+8 -6
View File
@@ -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`,或缩小查询范围。
+3 -1
View File
@@ -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 字段别名。
+3 -3
View File
@@ -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
View File
@@ -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、字段权限和行级权限是最终权限边界。
+4 -1
View File
@@ -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": {
+191
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
};
+105
View File
@@ -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);
});
+52
View File
@@ -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"]),
+13
View File
@@ -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");
+32
View File
@@ -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
View File
@@ -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 {