fix(mcp): use CodeBuddy project configuration path

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
This commit is contained in:
FenjuFu
2026-09-10 05:25:50 +00:00
parent 3f7fa1dedb
commit 57636a2768
5 changed files with 181 additions and 6 deletions
+14 -2
View File
@@ -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.
+14 -2
View File
@@ -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` 标为待批准,需在交互式会话中确认一次。
+107
View File
@@ -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);
});
+45 -1
View File
@@ -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
View File
@@ -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