Merge pull request #10 from Donaldttt/feat/fixed-justconn-endpoint
Release / release (push) Canceled after 0s

feat: fix NocoBase endpoint to justconn.top
This commit is contained in:
Donaldttt
2026-08-15 14:04:36 +08:00
committed by GitHub
12 changed files with 64 additions and 75 deletions
+7 -9
View File
@@ -2,11 +2,12 @@
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在隐藏提示中输入自己的 NocoBase Authorize Key。
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、取得 NocoBase URL、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。
如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。
## 完成标准
- 下载本文件所在的同一固定 Release,不使用默认分支;
- 只连接固定的 NocoBase API `https://justconn.top/api`;
- MCP 在用户本机以 stdio 运行,默认只读;
- 认证属于当前用户自己的个人 Authorize Key 和业务角色;
- 当前 MCP 客户端已注册 `nocobase-crm`;
@@ -21,16 +22,13 @@
- 不静默安装 Git、Node.js、包管理器或其他系统工具。
- 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。
- 默认不启用写入、原始数据、后台服务或网络监听。
- 不询问或接受其他 NocoBase URL,也不允许通过参数、配置或凭证文件覆盖固定 API 地址。
## 安装流程
### 1. 确认输入与 Release 权限
### 1. 确认本机位置与 Release 权限
取得:
- NocoBase 应用 URL,例如 `https://nocobase.example`;
- 当前 Agent/MCP 客户端及其配置路径;
- 普通用户应用数据目录。
确定当前 Agent/MCP 客户端及其配置路径,以及普通用户应用数据目录。NocoBase API 已固定为 `https://justconn.top/api`,不需要向用户询问地址。
从本文件 URL 解析固定 Release Tag。读取同一 Release 的资产列表,只接受:
@@ -79,10 +77,10 @@ npm install --prefix <install-prefix> --omit=dev <verified-release.tgz>
在用户可直接控制的本机终端运行:
```bash
<installed-command> setup --api-base-url <nocobase-url> --json
<installed-command> setup --json
```
安装程序会先验证安全 TTY,再显示隐藏 Key 提示;没有安全 TTY 时会在写文件前停止。Key 通过 `roles:check` 验证成功后才保存,并立即执行 `crm_health`。Key 不会出现在返回 JSON、命令参数、MCP 配置或日志中。
安装程序会先验证安全 TTY,再显示固定 API 地址和隐藏 Key 提示;没有安全 TTY 时会在写文件前停止。Key 通过 `https://justconn.top/api/roles:check` 验证成功后才保存,并立即执行 `crm_health`。凭证文件只保存 Key,不保存或接受 API 地址。Key 不会出现在返回 JSON、命令参数、MCP 配置或日志中。
POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。不要复制其他用户的凭证文件;每个操作系统用户都必须单独设置自己的 Key。
+4 -4
View File
@@ -7,7 +7,7 @@
正式 Release 是一个已打包的 npm tarball,内部包含 MCP SDK 和运行代码。用户电脑只需要:
- Node.js 20.11 或更高版本(npm 随 Node.js 一起提供);
- 能访问目标 NocoBase;
- 能访问固定的 NocoBase 环境 `https://justconn.top`;
- 用户自己的个人 Authorize Key。
用户不再需要 Git、Python、uv、Python 包或 `nb CLI`。安装也不会全局安装本项目,而是放到用户自己的应用数据目录。
@@ -40,7 +40,7 @@ node dist/index.js --help
开发时初始化个人配置:
```bash
node dist/index.js setup --api-base-url https://nocobase.example --json
node dist/index.js setup --json
```
Authorize Key 只能输入到隐藏提示。不要把 Key 放进参数、环境变量、聊天、`config.json`、MCP 客户端配置或日志。
@@ -48,7 +48,7 @@ Authorize Key 只能输入到隐藏提示。不要把 Key 放进参数、环境
可用命令:
```text
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
nocobase-crm-mcp setup [--config <path>] [--json]
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
nocobase-crm-mcp doctor [--config <path>] [--json]
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
@@ -71,6 +71,6 @@ nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
AI 客户端 → 本机 TypeScript MCP → HTTPS NocoBase API → 当前用户 Key 对应的 ACL
```
MCP 不缓存共享身份。每个请求都会从当前操作系统用户的私有凭证文件读取 Key,因此撤销或替换 Key 后无需重新生成 MCP 客户端配置。远程地址必须使用 HTTPS(回环地址除外),跨域重定向会在转发 Authorization 前被拒绝,错误内容会脱敏。
MCP 不缓存共享身份。每个请求都会从当前操作系统用户的私有凭证文件读取 Key,因此撤销或替换 Key 后无需重新生成 MCP 客户端配置。API 地址固定为 `https://justconn.top/api`,不接受安装参数或凭证文件覆盖;跨域重定向会在转发 Authorization 前被拒绝,错误内容会脱敏。
内部结构见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md),运维与权限验收见 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。
+2 -2
View File
@@ -9,7 +9,7 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
## 模块边界
- `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。
- `src/credentials.ts` 负责 URL 安全校验、隐藏 TTY 输入和用户级私有凭证文件。Key 不进入 argv、环境变量、CRM 配置或 MCP 配置。
- `src/credentials.ts` 固定 `https://justconn.top/api`、负责隐藏 TTY 输入和用户级私有凭证文件。凭证文件只保存 Key;Key 不进入 argv、环境变量、CRM 配置或 MCP 配置。
- `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。
- `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。
- `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。
@@ -18,7 +18,7 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien
## 身份与网络安全
- 每个请求重新读取当前 OS 用户的凭证文件,NocoBase ACL 是最终权限边界。
- 非回环 HTTP URL 被拒绝;远程 Key 只能发往 HTTPS。
- 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。
- Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。
- Token 会从 HTTP 错误中替换为 `<redacted>`。
- stdio 是唯一默认传输,不创建后台服务或监听端口。
+2 -2
View File
@@ -5,7 +5,7 @@
每位用户在自己的电脑运行 MCP,并使用绑定本人业务身份的个人 Authorize Key:
```text
本机 AI 客户端 → 本机 TypeScript MCP → 远程 NocoBase API → 本人 ACL
本机 AI 客户端 → 本机 TypeScript MCP → https://justconn.top/api → 本人 ACL
```
项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。
@@ -53,4 +53,4 @@ Agent 下载新的固定 Release tarball并验证 SHA-256,再对同一隔离
- `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。
- `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。
- 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。
- 跨源重定向错误:修正 NocoBase API URL或代理配置;不要关闭同源保护。
- 跨源重定向错误:检查 `https://justconn.top/api` 的反向代理配置;不要关闭同源保护。
+5 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 2,
"id": "com.nocobase.crm-mcp",
"name": "NocoBase CRM MCP",
"version": "0.3.0",
"version": "0.3.1",
"distribution": {
"repository": "https://github.com/Donaldttt/nocobase-mcp",
"releaseRef": "must-match-v<version>",
@@ -19,6 +19,10 @@
"nocobaseCli": false,
"python": false
},
"nocobase": {
"apiBaseUrl": "https://justconn.top/api",
"userConfigurableEndpoint": false
},
"installation": {
"requiredTools": ["node", "npm"],
"sourceCheckout": false,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.0",
"version": "0.3.1",
"license": "UNLICENSED",
"bin": {
"nocobase-crm-mcp": "dist/index.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@donaldttt/nocobase-crm-mcp",
"version": "0.3.0",
"version": "0.3.1",
"description": "Local NocoBase CRM MCP server using each user's personal Authorize Key",
"type": "module",
"private": true,
+12 -11
View File
@@ -6,7 +6,7 @@ import { loadConfig } from "./config.js";
import {
credentialsPath,
loadCredentials,
normalizeApiBaseUrl,
NOCOBASE_API_BASE_URL,
readHidden,
saveCredentials,
} from "./credentials.js";
@@ -18,7 +18,6 @@ import { VERSION } from "./version.js";
interface ParsedArgs {
command: "serve" | "setup" | "doctor" | "client-config" | "help" | "version";
apiBaseUrl?: string;
config?: string;
enableWrites: boolean;
json: boolean;
@@ -80,27 +79,29 @@ export function createRuntime(configPath: string): {
}
async function setup(args: ParsedArgs): Promise<void> {
if (!args.apiBaseUrl) throw new Error("setup requires --api-base-url <https://nocobase.example>");
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
}
const apiBaseUrl = normalizeApiBaseUrl(args.apiBaseUrl);
process.stderr.write(
`This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` +
"Create a revocable personal Authorize Key in NocoBase for your own business account.\n" +
"Do not paste the Key into Agent chat. Enter it only in the hidden prompt below.\n",
);
const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl, authorizeKey }));
const verificationClient = new NocoBaseClient(() => ({
apiBaseUrl: NOCOBASE_API_BASE_URL,
authorizeKey,
}));
await verificationClient.verifyCredentials();
const configPath = ensureConfig(args.config);
saveCredentials({ apiBaseUrl, authorizeKey });
saveCredentials({ authorizeKey });
const runtime = createRuntime(configPath);
const health = await runtime.crm.crmHealth();
printResult(
{
ok: true,
apiBaseUrl,
apiBaseUrl: NOCOBASE_API_BASE_URL,
credentialsPath: credentialsPath(),
keyExposure: "private-tty-and-0600-local-file-only",
configPath,
@@ -165,11 +166,10 @@ function parseArgs(argv: string[]): ParsedArgs {
else if (flag === "--version" || flag === "-v") parsed.command = "version";
else if (flag === "--enable-writes") parsed.enableWrites = true;
else if (flag === "--json") parsed.json = true;
else if (flag === "--api-base-url" || flag === "--config") {
else if (flag === "--config") {
const value = argv[index + 1];
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
if (flag === "--api-base-url") parsed.apiBaseUrl = value;
else parsed.config = value;
parsed.config = value;
index += 1;
} else throw new Error(`Unknown option: ${flag}`);
index += 1;
@@ -185,11 +185,12 @@ function helpText(): string {
return `NocoBase CRM MCP ${VERSION}
Usage:
nocobase-crm-mcp setup --api-base-url <url> [--config <path>] [--json]
nocobase-crm-mcp setup [--config <path>] [--json]
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
nocobase-crm-mcp doctor [--config <path>] [--json]
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}.
The setup command accepts the personal Authorize Key only through a hidden local TTY prompt.
The default stdio server is read-only.
`;
+10 -24
View File
@@ -8,21 +8,7 @@ export interface Credentials {
authorizeKey: string;
}
export function normalizeApiBaseUrl(value: string): string {
const raw = value.trim();
if (!raw) throw new Error("NocoBase URL is required");
const url = new URL(raw);
if (!(["http:", "https:"] as string[]).includes(url.protocol))
throw new Error("NocoBase URL must use HTTP or HTTPS");
if (url.username || url.password || url.search || url.hash) {
throw new Error("NocoBase URL must not contain credentials, a query string, or a fragment");
}
const local = url.hostname === "localhost" || url.hostname === "::1" || url.hostname.startsWith("127.");
if (url.protocol !== "https:" && !local)
throw new Error("A remote Authorize Key may only be sent over HTTPS");
url.pathname = `${url.pathname.replace(/\/+$/, "")}${url.pathname.replace(/\/+$/, "").endsWith("/api") ? "" : "/api"}`;
return url.toString().replace(/\/$/, "");
}
export const NOCOBASE_API_BASE_URL = "https://justconn.top/api";
export function credentialsPath(): string {
const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim();
@@ -38,12 +24,12 @@ export function credentialsPath(): string {
return join(base, "nocobase-crm-mcp", "credentials.json");
}
export function saveCredentials(credentials: Credentials, path = credentialsPath()): void {
const normalized: Credentials = {
apiBaseUrl: normalizeApiBaseUrl(credentials.apiBaseUrl),
authorizeKey: credentials.authorizeKey.trim(),
};
if (!normalized.authorizeKey) throw new Error("Authorize Key must not be blank");
export function saveCredentials(
credentials: Pick<Credentials, "authorizeKey">,
path = credentialsPath(),
): void {
const authorizeKey = credentials.authorizeKey.trim();
if (!authorizeKey) throw new Error("Authorize Key must not be blank");
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
if (process.platform !== "win32") {
try {
@@ -56,7 +42,7 @@ export function saveCredentials(credentials: Credentials, path = credentialsPath
}
const descriptor = openSync(path, "w", 0o600);
try {
writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
writeFileSync(descriptor, `${JSON.stringify({ authorizeKey }, null, 2)}\n`, "utf8");
} finally {
closeSync(descriptor);
}
@@ -69,12 +55,12 @@ export function loadCredentials(path = credentialsPath()): Credentials {
throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`);
}
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed) || typeof parsed.apiBaseUrl !== "string" || typeof parsed.authorizeKey !== "string") {
if (!isRecord(parsed) || typeof parsed.authorizeKey !== "string") {
throw new Error("Credential file is invalid; run setup again");
}
const authorizeKey = parsed.authorizeKey.trim();
if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again");
return { apiBaseUrl: normalizeApiBaseUrl(parsed.apiBaseUrl), authorizeKey };
return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey };
}
export async function readHidden(prompt: string): Promise<string> {
+1 -1
View File
@@ -423,7 +423,7 @@ async function safeTool(operation: () => Promise<ToolPayload>): Promise<ToolPayl
error: {
code: error.statusCode === 408 ? "nocobase_request_timeout" : "nocobase_request_failed",
message: error.message,
hint: "Check the NocoBase URL, ACL, collection mapping, and query parameters.",
hint: "Check justconn.top availability, ACL, collection mapping, and query parameters.",
retryable: error.retryable,
},
};
+9 -5
View File
@@ -9,7 +9,7 @@ import { VERSION } from "../src/version.js";
test("package, manifest, and runtime versions agree with no runtime npm dependencies", () => {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const manifest = JSON.parse(readFileSync(new URL("../install-manifest.json", import.meta.url), "utf8"));
assert.equal(VERSION, "0.3.0");
assert.equal(VERSION, "0.3.1");
assert.equal(packageJson.version, VERSION);
assert.equal(manifest.version, VERSION);
assert.deepEqual(packageJson.dependencies, undefined);
@@ -31,10 +31,7 @@ test("setup refuses a non-TTY before creating credential files", async () => {
const previous = process.env.NOCOBASE_MCP_CREDENTIALS;
process.env.NOCOBASE_MCP_CREDENTIALS = path;
try {
await assert.rejects(
runCli(["setup", "--api-base-url", "https://nb.example", "--json"]),
/local terminal/,
);
await assert.rejects(runCli(["setup", "--json"]), /local terminal/);
assert.equal(existsSync(path), false);
assert.equal(existsSync(join(directory, "config.json")), false);
} finally {
@@ -42,3 +39,10 @@ test("setup refuses a non-TTY before creating credential files", async () => {
else process.env.NOCOBASE_MCP_CREDENTIALS = previous;
}
});
test("setup rejects API endpoint overrides", async () => {
await assert.rejects(
runCli(["setup", "--api-base-url", "https://other.example"]),
/Unknown option: --api-base-url/,
);
});
+8 -12
View File
@@ -4,24 +4,20 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { DEFAULT_CONFIG, loadConfig } from "../src/config.js";
import { loadCredentials, normalizeApiBaseUrl, saveCredentials } from "../src/credentials.js";
import { loadCredentials, NOCOBASE_API_BASE_URL, saveCredentials } from "../src/credentials.js";
test("normalizes application URLs to the NocoBase API base", () => {
assert.equal(normalizeApiBaseUrl("https://nb.example/app/"), "https://nb.example/app/api");
assert.equal(normalizeApiBaseUrl("http://127.0.0.1:13000/api"), "http://127.0.0.1:13000/api");
assert.throws(() => normalizeApiBaseUrl("http://nb.example"), /HTTPS/i);
assert.throws(() => normalizeApiBaseUrl("https://user:pass@nb.example"), /credentials/i);
});
test("stores and reads credentials from a private file", () => {
test("stores only the Key and always loads the fixed NocoBase endpoint", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credentials-"));
const path = join(directory, "private", "credentials.json");
saveCredentials({ apiBaseUrl: "https://nb.example", authorizeKey: "secret" }, path);
saveCredentials({ authorizeKey: "secret" }, path);
assert.deepEqual(loadCredentials(path), {
apiBaseUrl: "https://nb.example/api",
apiBaseUrl: NOCOBASE_API_BASE_URL,
authorizeKey: "secret",
});
assert.match(readFileSync(path, "utf8"), /secret/);
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { authorizeKey: "secret" });
writeFileSync(path, JSON.stringify({ apiBaseUrl: "https://legacy.example/api", authorizeKey: "secret" }));
assert.equal(loadCredentials(path).apiBaseUrl, NOCOBASE_API_BASE_URL);
if (process.platform !== "win32") {
chmodSync(path, 0o644);
assert.throws(() => loadCredentials(path), /0600/);