diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a2e1cb49..8a2194ca 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -707,18 +707,30 @@ Where each tool's servers land: |---|---|---| | claude | `~/.claude.json` | `/.mcp.json` | | cursor | `~/.cursor/mcp.json` | `/.cursor/mcp.json` | -| codebuddy / workbuddy | `~/./mcp.json` | `/./mcp.json` | +| codebuddy | `~/.codebuddy/mcp.json` | `/.mcp.json` | +| workbuddy | `~/.workbuddy/mcp.json` | `/.workbuddy/mcp.json` | | codex | `~/.codex/config.toml` | not supported | | qoder | `~/.qoder/settings.json` | `/.qoder/settings.json` | | opencode | `~/.config/opencode/opencode.json` | `/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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 9c501bac..1c522649 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -692,18 +692,30 @@ servers: |---|---|---| | claude | `~/.claude.json` | `/.mcp.json` | | cursor | `~/.cursor/mcp.json` | `/.cursor/mcp.json` | -| codebuddy / workbuddy | `~/./mcp.json` | `/./mcp.json` | +| codebuddy | `~/.codebuddy/mcp.json` | `/.mcp.json` | +| workbuddy | `~/.workbuddy/mcp.json` | `/.workbuddy/mcp.json` | | codex | `~/.codex/config.toml` | 不支持 | | qoder | `~/.qoder/settings.json` | `/.qoder/settings.json` | | opencode | `~/.config/opencode/opencode.json` | `/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` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/e2e/codebuddy-mcp.test.ts b/src/__tests__/e2e/codebuddy-mcp.test.ts new file mode 100644 index 00000000..12c11257 --- /dev/null +++ b/src/__tests__/e2e/codebuddy-mcp.test.ts @@ -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); +}); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 4225817a..4f18b403 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -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']) { diff --git a/src/types.ts b/src/types.ts index f5074b2a..675f8880 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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