mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
fix(mcp): use CodeBuddy project configuration path
Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
This commit is contained in:
+14
-2
@@ -707,18 +707,30 @@ Where each tool's servers land:
|
||||
|---|---|---|
|
||||
| claude | `~/.claude.json` | `<project>/.mcp.json` |
|
||||
| cursor | `~/.cursor/mcp.json` | `<project>/.cursor/mcp.json` |
|
||||
| codebuddy / workbuddy | `~/.<tool>/mcp.json` | `<project>/.<tool>/mcp.json` |
|
||||
| codebuddy | `~/.codebuddy/mcp.json` | `<project>/.mcp.json` |
|
||||
| workbuddy | `~/.workbuddy/mcp.json` | `<project>/.workbuddy/mcp.json` |
|
||||
| codex | `~/.codex/config.toml` | not supported |
|
||||
| qoder | `~/.qoder/settings.json` | `<project>/.qoder/settings.json` |
|
||||
| opencode | `~/.config/opencode/opencode.json` | `<project>/opencode.json` |
|
||||
|
||||
|
||||
CodeBuddy Code's [MCP documentation](https://www.codebuddy.ai/docs/cli/mcp)
|
||||
lists the project root's `.mcp.json` as its preferred project configuration.
|
||||
This is separate from TeamAI's user-scope `~/.codebuddy/mcp.json` target.
|
||||
Explicit `toolPaths.codebuddy.mcpProject` values in `teamai.yaml` still take
|
||||
precedence. For an existing team that pins run
|
||||
`teamai mcp remove` in the affected workspace before changing that value to
|
||||
`.mcp.json`, then run `teamai mcp inject`. Review and preserve any personal
|
||||
servers in either file; TeamAI does not migrate or delete the old file.
|
||||
Claude Code also reads the root `.mcp.json`, so this file is shared by both tools.
|
||||
|
||||
Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`.
|
||||
|
||||
**Secrets.** Write `${VAR}`, never a literal, in `mcp.yaml`. Values resolve from the environment, then from `env/env.yaml` → `~/.teamai/env`. Unresolved variables skip the server with a hint.
|
||||
|
||||
teamai **resolves every `${VAR}` to its value and writes it verbatim** into each tool's config (new files are created `0600`). It does not rely on any tool's own env-var expansion: that expansion is fragile — most decisively, IDEs launched from the GUI (Dock/Launchpad) never inherit your shell's exported variables, so a `${VAR}` placeholder expands to empty and the server 401s. Resolving to plaintext makes the token present no matter how the tool is started.
|
||||
|
||||
> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.cursor/mcp.json`, `.codebuddy/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret — add them to `.gitignore` and never commit them.
|
||||
> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret — add them to `.gitignore` and never commit them.
|
||||
|
||||
Claude Code may show project `.mcp.json` servers as pending approval until you accept them once in an interactive session.
|
||||
|
||||
|
||||
@@ -692,18 +692,30 @@ servers:
|
||||
|---|---|---|
|
||||
| claude | `~/.claude.json` | `<project>/.mcp.json` |
|
||||
| cursor | `~/.cursor/mcp.json` | `<project>/.cursor/mcp.json` |
|
||||
| codebuddy / workbuddy | `~/.<tool>/mcp.json` | `<project>/.<tool>/mcp.json` |
|
||||
| codebuddy | `~/.codebuddy/mcp.json` | `<project>/.mcp.json` |
|
||||
| workbuddy | `~/.workbuddy/mcp.json` | `<project>/.workbuddy/mcp.json` |
|
||||
| codex | `~/.codex/config.toml` | 不支持 |
|
||||
| qoder | `~/.qoder/settings.json` | `<project>/.qoder/settings.json` |
|
||||
| opencode | `~/.config/opencode/opencode.json` | `<project>/opencode.json` |
|
||||
|
||||
|
||||
CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp)
|
||||
明确将项目根目录的 `.mcp.json` 列为首选项目配置。
|
||||
该路径与 TeamAI 的用户级目标 `~/.codebuddy/mcp.json` 相互独立。
|
||||
`teamai.yaml` 中显式设置的 `toolPaths.codebuddy.mcpProject` 仍然优先生效。
|
||||
已有团队若固定使用 `.codebuddy/mcp.json`,请先在对应工作区执行
|
||||
`teamai mcp remove`,再将该值改为 `.mcp.json`,最后运行
|
||||
`teamai mcp inject`。请检查并保留两处文件中自行添加的服务;
|
||||
TeamAI 不会迁移或删除旧文件。Claude Code 也读取根目录的 `.mcp.json`,
|
||||
因此两个工具共享该文件。
|
||||
|
||||
Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。
|
||||
|
||||
**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。
|
||||
|
||||
teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。
|
||||
|
||||
> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.cursor/mcp.json`、`.codebuddy/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。
|
||||
> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。
|
||||
|
||||
Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||
const CLI = path.join(ROOT, 'dist', 'index.js');
|
||||
|
||||
describe('CodeBuddy project MCP (e2e)', () => {
|
||||
let sandbox: string;
|
||||
let homeDir: string;
|
||||
let projectRoot: string;
|
||||
|
||||
function runCLI(args: string[]): Promise<{ code: number | null; output: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [CLI, ...args], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, FORCE_COLOR: '0' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', (data: Buffer) => { output += data.toString(); });
|
||||
child.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error('CLI timed out: ' + args.join(' ') + '\\n' + output));
|
||||
}, 30_000);
|
||||
child.on('error', (error) => { clearTimeout(timer); reject(error); });
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ code, output }); });
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(CLI)) throw new Error('Run npm run build before the E2E test.');
|
||||
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-codebuddy-mcp-e2e-'));
|
||||
homeDir = path.join(sandbox, 'home');
|
||||
projectRoot = path.join(sandbox, 'project');
|
||||
const repoLocal = path.join(sandbox, 'team-repo');
|
||||
fs.mkdirSync(path.join(homeDir, '.codebuddy'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.codebuddy'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, '.teamai'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoLocal, 'mcp'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoLocal, 'teamai.yaml'), [
|
||||
'team: codebuddy-mcp-e2e',
|
||||
'repo: https://example.com/team.git',
|
||||
'provider: git',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(repoLocal, 'mcp', 'mcp.yaml'), [
|
||||
'servers:',
|
||||
' - name: team-codebuddy',
|
||||
' transport: http',
|
||||
' url: https://example.com/mcp',
|
||||
' tools: [codebuddy]',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(projectRoot, '.teamai', 'config.yaml'), [
|
||||
'repo:',
|
||||
` localPath: ${JSON.stringify(repoLocal)}`,
|
||||
' remote: https://example.com/team.git',
|
||||
'username: e2e-user',
|
||||
'scope: project',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { personal: { command: 'personal-server' } }, custom: true,
|
||||
}));
|
||||
fs.writeFileSync(path.join(homeDir, '.codebuddy', 'mcp.json'), '{"userConfig":true}');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists, injects and removes project MCP servers at the CodeBuddy reader path', async () => {
|
||||
const list = await runCLI(['mcp', 'list']);
|
||||
expect(list.code, list.output).toBe(0);
|
||||
expect(list.output).toContain(path.join(projectRoot, '.mcp.json'));
|
||||
expect(list.output).not.toContain(path.join(projectRoot, '.codebuddy', 'mcp.json'));
|
||||
|
||||
const inject = await runCLI(['mcp', 'inject']);
|
||||
expect(inject.code, inject.output).toBe(0);
|
||||
expect(inject.output).toMatch(/added\s+codebuddy\/team-codebuddy/);
|
||||
const projectFile = path.join(projectRoot, '.mcp.json');
|
||||
const readProject = () => JSON.parse(fs.readFileSync(projectFile, 'utf8'));
|
||||
expect(readProject()).toEqual({
|
||||
mcpServers: {
|
||||
personal: { command: 'personal-server' },
|
||||
'team-codebuddy': { type: 'http', url: 'https://example.com/mcp' },
|
||||
},
|
||||
custom: true,
|
||||
});
|
||||
expect(fs.existsSync(path.join(projectRoot, '.codebuddy', 'mcp.json'))).toBe(false);
|
||||
|
||||
const repeat = await runCLI(['mcp', 'inject']);
|
||||
expect(repeat.code, repeat.output).toBe(0);
|
||||
expect(repeat.output).toContain('Already up to date.');
|
||||
|
||||
const remove = await runCLI(['mcp', 'remove']);
|
||||
expect(remove.code, remove.output).toBe(0);
|
||||
expect(readProject()).toEqual({
|
||||
mcpServers: { personal: { command: 'personal-server' } }, custom: true,
|
||||
});
|
||||
expect(fs.readFileSync(path.join(homeDir, '.codebuddy', 'mcp.json'), 'utf8'))
|
||||
.toBe('{"userConfig":true}');
|
||||
}, 150_000);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ vi.mock('../utils/logger.js', () => ({
|
||||
}));
|
||||
|
||||
import { reconcileMcpForConfig, resolveMcpTargets, spliceCodexBlock, codexServerNames } from '../mcp-reconcile.js';
|
||||
import type { TeamaiConfig, LocalConfig } from '../types.js';
|
||||
import { TeamaiConfigSchema, type TeamaiConfig, type LocalConfig } from '../types.js';
|
||||
|
||||
const TOOL_PATHS = {
|
||||
claude: { skills: '.claude/skills', settings: '.claude/settings.json', mcp: '.claude.json', mcpProject: '.mcp.json' },
|
||||
@@ -334,6 +334,50 @@ servers:
|
||||
expect(await fse.pathExists(path.join(projectRoot, '.mcp.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses CodeBuddy project defaults without changing user scope or personal servers', async () => {
|
||||
const projectRoot = path.join(tmpDir, 'codebuddy-project');
|
||||
await fse.ensureDir(path.join(projectRoot, '.codebuddy'));
|
||||
await fse.ensureDir(path.join(projectRoot, '.workbuddy'));
|
||||
await fse.ensureDir(path.join(homeDir, '.codebuddy'));
|
||||
const projectFile = path.join(projectRoot, '.mcp.json');
|
||||
const userFile = path.join(homeDir, '.codebuddy', 'mcp.json');
|
||||
const personal = { mcpServers: { personal: { command: 'my-server' } }, custom: true };
|
||||
await fse.writeJson(projectFile, personal);
|
||||
await fse.writeJson(userFile, personal);
|
||||
const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'r', provider: 'git' });
|
||||
const projectConfig: LocalConfig = { ...localConfig, scope: 'project', projectRoot };
|
||||
await writeMcpYaml(`
|
||||
servers:
|
||||
- name: team-codebuddy
|
||||
transport: http
|
||||
url: https://example.com/mcp
|
||||
tools: [codebuddy]
|
||||
`);
|
||||
|
||||
const targets = await resolveMcpTargets(defaults, projectConfig);
|
||||
expect(targets.find((target) => target.tool === 'codebuddy')?.file).toBe(projectFile);
|
||||
expect(targets.find((target) => target.tool === 'workbuddy')?.file)
|
||||
.toBe(path.join(projectRoot, '.workbuddy', 'mcp.json'));
|
||||
const userTargets = await resolveMcpTargets(defaults, localConfig);
|
||||
expect(userTargets.find((target) => target.tool === 'codebuddy')?.file).toBe(userFile);
|
||||
|
||||
await reconcileMcpForConfig(defaults, projectConfig);
|
||||
expect(await fse.readJson(projectFile)).toEqual({
|
||||
...personal,
|
||||
mcpServers: {
|
||||
...personal.mcpServers,
|
||||
'team-codebuddy': { type: 'http', url: 'https://example.com/mcp' },
|
||||
},
|
||||
});
|
||||
expect(await fse.pathExists(path.join(projectRoot, '.codebuddy', 'mcp.json'))).toBe(false);
|
||||
expect(await fse.readJson(userFile)).toEqual(personal);
|
||||
expect((await reconcileMcpForConfig(defaults, projectConfig)).wrote).toBe(false);
|
||||
|
||||
await reconcileMcpForConfig(defaults, projectConfig, { removeAll: true });
|
||||
expect(await fse.readJson(projectFile)).toEqual(personal);
|
||||
expect(await fse.readJson(userFile)).toEqual(personal);
|
||||
});
|
||||
|
||||
it('resolves a project secret to plaintext in every tool, keyed off `type`', async () => {
|
||||
const projectRoot = path.join(tmpDir, 'proj2');
|
||||
for (const d of ['.claude', '.cursor', '.codebuddy']) {
|
||||
|
||||
+1
-1
@@ -282,7 +282,7 @@ export const TeamaiConfigSchema = z.object({
|
||||
// .zcode/config.json (a different key), which the Claude writer cannot
|
||||
// emit — so no mcpProject. ZCode has no user-level rules dir convention.
|
||||
zcode: { skills: '.zcode/skills', agents: '.zcode/agents', settings: '.zcode/cli/config.json', mcp: '.agents/mcp.json' },
|
||||
codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents', mcp: '.codebuddy/mcp.json', mcpProject: '.codebuddy/mcp.json' },
|
||||
codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' },
|
||||
openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules', claudemd: '.openclaw/workspace/AGENTS.md' },
|
||||
hermes: { skills: '.hermes/skills', claudemd: 'AGENTS.md' },
|
||||
// DeepSeek Harness: skills synced to ~/.dsh/skills, which its skill-filesystem
|
||||
|
||||
Reference in New Issue
Block a user