feat: MCP 后端切到 deta.city 远程端点 + 账号登录门槛

- .mcp.json / cordis.patch.yml 改为远程 streamable-http(https://deta.city/mcp-api/mcp),凭据经环境变量 DETA_TOKEN
- 新增 bin/deta-login.mjs:deta.city 账号登录,写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600)
- 新增 plugins/deta/README.md:端点/凭据语义、两种宿主安装、本地 stdio 开发形态
- 本地 stdio 形态改为默认注释关闭(仅本机开发,不校验账号、不隔离工作区)
This commit is contained in:
ringo
2026-09-10 10:53:43 +08:00
parent b5877769de
commit 9577d2e132
5 changed files with 366 additions and 37 deletions
+33 -8
View File
@@ -16,6 +16,10 @@ DETA 领域能力在各类 Agent harness 上的分发仓:**一份产物,多
## 安装(Codex)
```bash
# 先登录(一次性,需要 deta.city 账号):换取 DETA_TOKEN
node ~/Dev/ant/deta-agent-plugins/plugins/deta/bin/deta-login.mjs
source ~/.deta/env # 让 DETA_TOKEN 进入当前 shell 环境
# 从 Git(分发用)
codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git
@@ -26,6 +30,9 @@ codex plugin list
codex plugin add deta@deta
```
未登录(环境里没有 `DETA_TOKEN`)时,MCP 端点会返回 **401**——含义是
「当前环境没有可用的 deta.city 登录态」,重新执行上面的登录两步即可。
## 内容
| 插件 | 技能 | 能力 | 依赖 |
@@ -34,22 +41,37 @@ codex plugin add deta@deta
## 前置条件
`deta` 插件的 MCP 后端是领域服务的无头 MCP 服务:
| 项 | 值 |
|----|-----|
| MCP 端点 | `https://deta.city/mcp-api/mcp`(MCP Streamable HTTP) |
| 凭据来源 | **deta.city 账号**;`bin/deta-login.mjs` 登录取令牌,经环境变量 `DETA_TOKEN` 传给 harness |
| 凭据文件 | `~/.deta/credentials.json`(令牌 + 账号 + 端点)、`~/.deta/env`(`export DETA_TOKEN=...`),均 0600 |
端点除探活接口(服务端 `GET /health`,经 Nginx 前缀对外为 `/mcp-api/health`)外全部需要鉴权:
- 未带凭据 / 凭据失效 → `401`(提示重新登录,不回显凭据);
- 服务端认证服务不可用 → `500`,同样不放行;
- 各账号在服务端有独立工作区(`<工作区根>/<账号 ID>`),互不可见。
### 本地 stdio 形态(开发用,默认关闭)
本机开发时也可以直连领域服务的无头 MCP 服务(`bin/deta-mcp.sh`,stdio),
它**不校验 deta.city 账号、不隔离工作区**,仅供本机迭代领域能力:
```bash
cd ~/Dev/ant/ant-copilot && npm run build
node dist/src/headless/mcp-server.js # 冒烟:应等待 stdin/stdout
```
环境变量:
该形态的环境变量:
| 变量 | 缺省 | 说明 |
|------|------|------|
| `DETA_SERVER_ROOT` | `~/Dev/ant/ant-copilot` | 领域服务仓库根目录(内部路径) |
| `ANT_HEADLESS_WORKSPACE` | `<cwd>/workspace` | AML 工作目录(`aml.xml` / `building.json` 落盘处,名称由底层服务决定) |
编译与校验类工具需要编译服务在线(`localhost:8000`);未在线时返回明确错误,
读写类工具可离线工作。
编译与校验类工具需要编译服务在线(服务端为 `DETA_GSA_URL`,缺省 `localhost:8000`);
未在线时返回明确错误,读写类工具可离线工作。
## 本地迭代
@@ -117,8 +139,9 @@ node apps/cli/lib/bin.js --profile headless --dump-config \
# 输出中应出现 `# == .../plugins/deta/cordis.patch.yml` 与 deta-mcp 行
```
`cordis.patch.yml` 里远程端点那段已按公开形态写好(`streamable-http` + Bearer 头),
当前默认启用的是本地 stdio 行;服务端化完成后注释切换即可。
`cordis.patch.yml` 当前默认启用**远程端点**(`streamable-http` +
`Authorization: Bearer ${DETA_TOKEN}`,指向 `https://deta.city/mcp-api/mcp`);
本地 stdio 段保留为注释、标注「本机开发用,默认关闭」,需要时手工切换。
## 已知缺陷(实测,2026-09-10)
@@ -160,9 +183,11 @@ Duplicate namespace name 'deta'; Namespace names must be unique.
.agents/plugins/api_marketplace.json # Codex API key 登录用户市场
plugins/deta/ # 同一目录 = Codex 插件根 = dsh 组合包根
├── .codex-plugin/plugin.json # Codex 插件清单
├── .mcp.json # Codex MCP 声明(含 Codex 私有键)
├── .mcp.json # Codex MCP 声明(远程 streamable-http)
├── package.json # dsh 组合包声明(dsh.bundle.patch)
├── cordis.patch.yml # dsh 配置层(挂载 MCP 服务)
├── bin/deta-mcp.sh # 本地 MCP 启动器(开发用)
├── bin/deta-login.mjs # 登录助手(~/.deta/credentials.json + ~/.deta/env)
├── bin/deta-mcp.sh # 本地 stdio 启动器(开发用,默认关闭)
├── README.md # 插件说明(安装/凭据/401 语义)
└── skills/aml-design/SKILL.md # 领域技能(两宿主共享)
```
+3 -9
View File
@@ -1,15 +1,9 @@
{
"mcpServers": {
"deta": {
"command": "./bin/deta-mcp.sh",
"args": [],
"cwd": ".",
"env_vars": [
"DETA_SERVER_ROOT",
"ANT_HEADLESS_WORKSPACE"
],
"default_tools_approval_mode": "approve",
"supports_parallel_tool_calls": false
"type": "streamable-http",
"url": "https://deta.city/mcp-api/mcp",
"bearer_token_env_var": "DETA_TOKEN"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
# deta 插件(Codex 插件 / dsh 组合包)
DETA 领域能力(AML 建筑模型读写、编译校验、围护结构统计、空间邻接查询)
以**远程 MCP 服务**形态挂进 Agent harness:同一个目录既是 Codex 插件根,
也是 dsh 组合包根,两端共享 `skills/` 与同一份 MCP 后端。
## 端点与凭据
| 项 | 值 |
|----|-----|
| 端点 | `https://deta.city/mcp-api/mcp`(MCP Streamable HTTP) |
| 凭据来源 | **deta.city 账号**(令牌经 `POST https://deta.city/prod-api/login` 换取) |
| 传参方式 | 环境变量 `DETA_TOKEN`(Codex 侧 `bearer_token_env_var`;dsh 侧 `Authorization: Bearer ${DETA_TOKEN}`) |
端点除探活接口(服务端 `GET /health`,经 Nginx 前缀对外为 `/mcp-api/health`)外**全部需要鉴权**:
- 请求未带凭据、凭据失效/过期 → **401**(响应体为可读提示,不回显凭据)。
401 的含义是「当前环境没有可用的 deta.city 登录态」,不是工具或模型出错;
重新执行 `node bin/deta-login.mjs` 并 `source ~/.deta/env` 即可。
- 服务端认证服务不可用(`ANT_ADMIN_SERVER` 未配置或不可达)→ 500,同样不放行。
账号的工作区在服务端按账号隔离(`<工作区根>/<账号 ID>`),
不同账号的设计源码互不可见;同一账号的新会话继续使用自己的工作区。
## 安装(Codex)
```bash
# 1. 先登录(一次性;需要 deta.city 账号)
node ~/Dev/ant/deta-agent-plugins/plugins/deta/bin/deta-login.mjs
# → 写入 ~/.deta/credentials.json 与 ~/.deta/env(均 0600)
# 2. 让 DETA_TOKEN 进入当前 shell 环境
source ~/.deta/env
# 3. 安装插件
codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git
codex plugin add deta@deta
```
登录助手也支持自建/预发环境:`node bin/deta-login.mjs --base-url https://<host>`。
## 安装(dsh)
```bash
node ~/Dev/ant/deta-agent-plugins/plugins/deta/bin/deta-login.mjs
source ~/.deta/env
dsh plugin --profile <name> add ~/Dev/ant/deta-agent-plugins/plugins/deta
```
`cordis.patch.yml` 默认启用远程 `streamable-http` 段(Bearer 取自 `DETA_TOKEN`)。
## 本地 stdio 形态(开发用)
`bin/deta-mcp.sh` 走本机 stdio(`node dist/src/headless/mcp-server.js`),
**仅本机开发用,默认关闭**:不校验 deta.city 账号、不隔离工作区。
需要在本机迭代领域能力时,打开 `cordis.patch.yml` 里注释掉的 stdio 段
(并注释掉远程段),设置 `DETA_SERVER_ROOT` 指向已构建的领域服务仓库:
```bash
cd ~/Dev/ant/ant-copilot && npm run build
node dist/src/headless/mcp-server.js # 冒烟:应等待 stdin/stdout
```
## 文件
```
.codex-plugin/plugin.json Codex 插件清单
.mcp.json Codex MCP 声明(远程 streamable-http)
package.json dsh 组合包声明(dsh.bundle.patch)
cordis.patch.yml dsh 配置层(挂载 MCP 服务)
bin/deta-login.mjs 登录助手(写 ~/.deta/credentials.json + ~/.deta/env)
bin/deta-mcp.sh 本地 stdio 启动器(开发用)
skills/aml-design/SKILL.md 领域技能
```
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env node
/**
* DETA 登录助手(Node ESM,零第三方依赖)
*
* 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用:
*
* node bin/deta-login.mjs [--base-url https://deta.city]
*
* 流程:
* 1. 交互式读取用户名/密码(密码不回显);
* 2. POST <base>/prod-api/login { username, password };
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
* 4. 失败 → 打印服务端 msg,退出码非 0。
*
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
* 不写日志、不写 stderr、不写其它位置。
*/
import { mkdir, writeFile, chmod } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
const DEFAULT_BASE_URL = 'https://deta.city';
const REQUEST_TIMEOUT_MS = 20_000;
function fail(message) {
console.error(`[deta-login] ${message}`);
process.exit(1);
}
/** `--base-url <url>` / `--base-url=<url>`;缺省 https://deta.city,末尾斜杠去掉 */
function parseBaseUrl(argv) {
let baseUrl = process.env.DETA_BASE_URL || DEFAULT_BASE_URL;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--base-url') {
const next = argv[i + 1];
if (!next) fail('--base-url 缺少取值');
baseUrl = next;
i++;
} else if (arg.startsWith('--base-url=')) {
baseUrl = arg.slice('--base-url='.length);
} else if (arg === '-h' || arg === '--help') {
console.log('用法:node bin/deta-login.mjs [--base-url https://deta.city]');
process.exit(0);
} else {
fail(`未知参数:${arg}`);
}
}
return baseUrl.trim().replace(/\/+$/, '');
}
/**
* 读一行输入。hidden=true 时不回显(密码);
* 非 TTY(管道/CI)时同样按行读取,便于自动化。
*/
let pendingInput = '';
/** 从暂存缓冲里切出一行(跨 chunk 保留剩余数据),无完整行返回 null */
function takeLine() {
const index = pendingInput.search(/[\r\n]/);
if (index < 0) return null;
const line = pendingInput.slice(0, index);
const skip = pendingInput[index] === '\r' && pendingInput[index + 1] === '\n' ? 2 : 1;
pendingInput = pendingInput.slice(index + skip);
return line;
}
function readLine({ hidden }) {
return new Promise((resolve, reject) => {
// 上一次读取可能已经缓冲了后续行(管道一次性写入多行)
const buffered = takeLine();
if (buffered !== null) {
resolve(buffered);
return;
}
const stdin = process.stdin;
const isTTY = Boolean(stdin.isTTY);
let buffer = '';
let settled = false;
const cleanup = () => {
stdin.removeListener('data', onData);
stdin.removeListener('end', onEnd);
stdin.removeListener('error', onError);
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
stdin.pause();
};
const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
// TTY 下 raw mode 不回显回车,这里补一个换行;非 TTY(管道)不额外输出
if (isTTY) process.stdout.write('\n');
resolve(value);
};
const onData = (chunk) => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
if (!isTTY) {
// 非交互:不回显,按行切分,剩余数据留给下一次读取
pendingInput += text;
const line = takeLine();
if (line !== null) finish(line);
return;
}
for (const ch of [...text]) {
if (ch === '\n' || ch === '\r') return finish(buffer);
if (ch === '\u0003') {
// Ctrl-C
process.stdout.write('\n');
process.exit(130);
}
if (ch === '\u0004') return finish(buffer); // Ctrl-D
if (ch === '\u007f' || ch === '\b') {
if (buffer && !hidden) process.stdout.write('\b \b');
buffer = buffer.slice(0, -1);
continue;
}
buffer += ch;
if (!hidden) process.stdout.write(ch);
}
};
// 输入结束:非 TTY 时把残留(末行无换行)也交出去
const onEnd = () => {
if (!isTTY && pendingInput) {
const rest = pendingInput;
pendingInput = '';
finish(rest);
return;
}
finish(buffer);
};
const onError = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
stdin.resume();
stdin.on('data', onData);
stdin.on('end', onEnd);
stdin.on('error', onError);
});
}
/** 单引号安全转义,供 `export DETA_TOKEN='...'` 使用 */
function shellSingleQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
async function writePrivateFile(filePath, content) {
await writeFile(filePath, content, { mode: 0o600 });
// 已存在的文件不被 mode 改写,显式收紧
await chmod(filePath, 0o600).catch(() => undefined);
}
async function main() {
const baseUrl = parseBaseUrl(process.argv.slice(2));
process.stdout.write(`DETA 登录(${baseUrl})\n`);
process.stdout.write('用户名:');
const username = (await readLine({ hidden: false })).trim();
if (!username) fail('用户名不能为空');
process.stdout.write('密码:');
const password = await readLine({ hidden: true });
if (!password) fail('密码不能为空');
const loginUrl = `${baseUrl}/prod-api/login`;
let response;
try {
response = await fetch(loginUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
fail(`无法连接 ${loginUrl}(${err instanceof Error ? err.message : String(err)})`);
}
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
const msg = payload?.msg || `登录失败(HTTP ${response.status})`;
fail(msg);
}
const loginUsername = typeof payload.username === 'string' && payload.username ? payload.username : username;
const dir = join(homedir(), '.deta');
await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700).catch(() => undefined);
const credentials = {
token,
username: loginUsername,
baseUrl,
loginAt: new Date().toISOString(),
};
const credentialsPath = join(dir, 'credentials.json');
const envPath = join(dir, 'env');
await writePrivateFile(credentialsPath, `${JSON.stringify(credentials, null, 2)}\n`);
await writePrivateFile(
envPath,
`# DETA MCP 凭据(由 bin/deta-login.mjs 生成,勿提交版本库)\n` +
`# 用法:source ~/.deta/env\n` +
`export DETA_TOKEN=${shellSingleQuote(token)}\n`,
);
// token 只在这里(stdout)出现一次;stderr 与其它文件均不含凭据
console.log(`DETA_TOKEN=${token}`);
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`);
console.log(`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`);
}
main().catch((err) => {
fail(err instanceof Error ? err.message : String(err));
});
+28 -20
View File
@@ -4,29 +4,37 @@
# 本层只做一件事:把 DETA 领域能力作为 MCP 服务挂进 harness。
# 工具以 mcp__deta__<tool> 暴露(与 Codex 侧同名)。
#
# ── 公开发布形态(远程端点,推荐)─────────────────────────────
# 服务端化完成后启用本节,并删除下面的本地 stdio 节。
# 凭据:deta.city 账号。先 `node bin/deta-login.mjs` 登录(写入 ~/.deta/credentials.json
# 与 ~/.deta/env),再 `source ~/.deta/env` 让 DETA_TOKEN 进入环境。
# 端点 https://deta.city/mcp-api/mcp 除 /health 外都要鉴权,无凭据一律 401。
#
# ── 远程端点形态(Streamable HTTP,默认启用)───────────────────
# 服务端:deta.city 上的无头 MCP 服务(ant-copilot src/headless/mcp-http-server.ts),
# 经 Nginx 反代暴露;各账号的工作区在服务端按账号隔离。
#
- insert:
- id: deta-mcp
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: deta
transport: streamable-http
url: https://deta.city/mcp-api/mcp
headers:
Authorization: !!js '`Bearer ${process.env.DETA_TOKEN ?? ''}`'
#
# ── 本地形态(本机开发用,默认关闭)───────────────────────────
# 走 stdio 直连本机已构建的领域服务,不校验 deta.city 账号、不隔离工作区,
# 只在迭代领域能力时临时启用:把下面整节取消注释,同时注释掉上面的远程节。
# 需要 DETA_SERVER_ROOT 指向已构建的领域服务仓库(缺省见启动器脚本)。
#
# - insert:
# - id: deta-mcp
# name: '@deepseek-ai/dsh-mcp-client'
# config:
# serverName: deta
# transport: streamable-http
# url: https://deta.city/workspace-api/mcp
# headers:
# Authorization: !!js '`Bearer ${process.env.DETA_TOKEN ?? ''}`'
#
# ── 本地形态(当前可用:本机已构建的领域服务,stdio)─────────
# 需要 DETA_SERVER_ROOT 指向已构建的领域服务仓库(缺省见启动器脚本)。
- insert:
- id: deta-mcp
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: deta
transport: stdio
command: node
args:
- !!js '`${process.env.DETA_SERVER_ROOT ?? process.env.HOME + "/Dev/ant/ant-copilot"}/dist/src/headless/mcp-server.js`'
env:
ANT_HEADLESS_WORKSPACE: !!js process.env.ANT_HEADLESS_WORKSPACE ?? ''
# transport: stdio
# command: node
# args:
# - !!js '`${process.env.DETA_SERVER_ROOT ?? process.env.HOME + "/Dev/ant/ant-copilot"}/dist/src/headless/mcp-server.js`'
# env:
# ANT_HEADLESS_WORKSPACE: !!js process.env.ANT_HEADLESS_WORKSPACE ?? ''