diff --git a/INSTALL.md b/INSTALL.md index bdccdbd..8c3404c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -102,27 +102,53 @@ npm install --prefix --omit=dev 定位安装后的命令:POSIX 为 `/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。运行 `--version` 和 `--help`,确认版本等于源码 `package.json`,并且帮助中包含本 Commit 安装协议使用的 `setup --no-browser` 选项;不一致时停止,不得继续注册旧二进制。 -### 4. 浏览器登录 NocoBase +### 4. 在登录前注册 MCP -运行: +先运行: ```bash - setup --json + 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 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 ``` diff --git a/README.md b/README.md index 2647f85..69c872e 100644 --- a/README.md +++ b/README.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`,或缩小查询范围。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c7139b..777a1ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 错误中替换为 ``。 - stdio 是唯一默认传输,不创建后台服务或监听端口。 ## 兼容规则 -- 保持 17 个只读工具名称、参数和主要响应形状稳定。 +- 保持 17 个 CRM 只读工具名称、参数和主要响应形状稳定,并固定提供 `nocobase_login`、`nocobase_login_status` 两个身份工具。 - 写模式只增加 `create_project`、`update_project`,不提供 delete/destroy。 - `include_raw` 默认在 MCP 边界拒绝。 - 配置继续接受 camelCase 与 snake_case 字段别名。 diff --git a/docs/LOCAL_DELIVERY.md b/docs/LOCAL_DELIVERY.md index 1063b77..fc03e1b 100644 --- a/docs/LOCAL_DELIVERY.md +++ b/docs/LOCAL_DELIVERY.md @@ -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 和字段/行级权限。 diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7a985e0..7ba6620 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -12,16 +12,18 @@ nocobase-crm-mcp client-config [--config ] [--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 ] [--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、字段权限和行级权限是最终权限边界。 diff --git a/install-manifest.json b/install-manifest.json index dd782b4..5c1f0fe 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -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": { diff --git a/src/auth-service.ts b/src/auth-service.ts new file mode 100644 index 0000000..cf13137 --- /dev/null +++ b/src/auth-service.ts @@ -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; + +export interface AuthService { + start(options?: { force?: boolean }): Promise; + status(): Promise; +} + +export interface NocoBaseAuthServiceOptions { + credentialsFile?: string; + now?: () => number; + openBrowser?: BrowserLoginOptions["openBrowser"]; + startLogin?: (options: BrowserLoginOptions) => Promise; + verifyCredentials?: (credentials: OAuthCredentials) => Promise; + 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; + private readonly verifyCredentials: (credentials: OAuthCredentials) => Promise; + private readonly onCredentialsSaved?: () => void; + private state?: AuthorizationState; + private startInFlight?: Promise; + + 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 { + 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 { + 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 { + 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 { + const client = new NocoBaseClient(() => ({ + apiBaseUrl: NOCOBASE_API_BASE_URL, + authorizeKey: credentials.accessToken, + })); + await client.verifyCredentials(); +} + +async function backgroundSleep(milliseconds: number): Promise { + await new Promise((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.", + }; +} diff --git a/src/cli.ts b/src/cli.ts index 4e3a64a..c2bea12 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { 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 { 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 { 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 { "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 { 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 { 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 { 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. `; } diff --git a/src/credentials.ts b/src/credentials.ts index cf1447b..7aa818a 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -27,6 +27,11 @@ export interface CredentialProviderOptions { refreshSkewMs?: number; } +export interface CredentialProvider { + (): Promise; + 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 { +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 | undefined; let refreshRetryAt = 0; + let generation = 0; - return async (): Promise => { + const provider = async (): Promise => { + 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; + 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 { @@ -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"); } } diff --git a/src/crm-service.ts b/src/crm-service.ts index 8c74901..5ec2b0f 100644 --- a/src/crm-service.ts +++ b/src/crm-service.ts @@ -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, diff --git a/src/oauth.ts b/src/oauth.ts index 24766f6..66f4445 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -53,6 +53,11 @@ export interface BrowserLoginOptions { sleep?: (milliseconds: number) => Promise; } +export interface BrowserLoginSession { + prompt: AuthorizationPrompt; + credentials: Promise; +} + export interface RefreshOptions { fetch?: typeof fetch; now?: () => number; @@ -69,6 +74,11 @@ export class OAuthProtocolError extends Error { } export async function loginWithBrowser(options: BrowserLoginOptions = {}): Promise { + const session = await startBrowserLogin(options); + return await session.credentials; +} + +export async function startBrowserLogin(options: BrowserLoginOptions = {}): Promise { 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( diff --git a/src/tools.ts b/src/tools.ts index da543c8..fb86576 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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, + 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): Promise { + 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((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((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(() => 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); +}); diff --git a/test/cli-package.test.ts b/test/cli-package.test.ts index 84babb9..dd382bf 100644 --- a/test/cli-package.test.ts +++ b/test/cli-package.test.ts @@ -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\/\.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, /
/); }); @@ -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"]), diff --git a/test/credentials-config.test.ts b/test/credentials-config.test.ts index 067e667..1729390 100644 --- a/test/credentials-config.test.ts +++ b/test/credentials-config.test.ts @@ -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"); diff --git a/test/crm-service.test.ts b/test/crm-service.test.ts index ea00e5f..dc6ed23 100644 --- a/test/crm-service.test.ts +++ b/test/crm-service.test.ts @@ -102,6 +102,38 @@ test("resolves owner candidates and returns ambiguity as structured data", async assert.equal((listed.error as Record).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 }> = []; + 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) => { + 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" }), diff --git a/test/tools.test.ts b/test/tools.test.ts index e3a6ff2..b03f21d 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -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; }> { 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 }; + 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 {