diff --git a/docs/usage-guide.md b/docs/usage-guide.md index e8df5eb..58e57e4 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -438,13 +438,13 @@ Where the tool can expand env vars itself, teamai keeps the secret off disk and | tool | on disk | |---|---| | Claude (project `.mcp.json`) | `${VAR}` | -| CodeBuddy | `${VAR}` | | Cursor | `${env:VAR}` | | Codex | `bearer_token_env_var` / `env_http_headers` (variable name only) | +| CodeBuddy | resolved to plaintext (see below) | -Everywhere else — Claude at **user** scope, or any placeholder Codex cannot express as a whole-header variable — the value is resolved and written verbatim into the target file (new files are created `0600`). +Everywhere else — Claude at **user** scope, CodeBuddy, or any placeholder Codex cannot express as a whole-header variable — the value is resolved and written verbatim into the target file (new files are created `0600`). -> ⚠️ A resolved (literal) secret only lands on disk in the fallback cases above. Cursor and CodeBuddy now keep the placeholder in every scope, so a committed `.cursor/mcp.json` / `.codebuddy/mcp.json` carries the variable name, not the value — the reader still needs `${VAR}` set in their environment. +> ⚠️ **CodeBuddy is resolved to plaintext on purpose.** Its IDE runs as a GUI app that never inherits your shell's exported variables, so a `${VAR}` placeholder would expand to empty and the server would 401. teamai therefore writes the resolved token into `.codebuddy/mcp.json`. Do not commit that file — add it to `.gitignore`. Cursor and Claude keep the placeholder in every scope, so a committed `.cursor/mcp.json` / `.mcp.json` carries the variable name, not the value. 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 312584d..2436af2 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -436,13 +436,13 @@ Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。归属记录在 `~/.teama | 工具 | 落盘内容 | |---|---| | Claude(项目级 `.mcp.json`) | `${VAR}` | -| CodeBuddy | `${VAR}` | | Cursor | `${env:VAR}` | | Codex | `bearer_token_env_var` / `env_http_headers`(只记变量名) | +| CodeBuddy | 解析为明文(见下) | -其余情况——用户级的 Claude,或 Codex 无法用「整个 header 一个变量」表达的占位符——会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。 +其余情况——用户级的 Claude、CodeBuddy,或 Codex 无法用「整个 header 一个变量」表达的占位符——会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。 -> ⚠️ 只有上述回退情况才会把解析后的密钥明文落盘。Cursor 与 CodeBuddy 现在在所有 scope 下都保留占位符,因此即便 `.cursor/mcp.json` / `.codebuddy/mcp.json` 被提交,里面记的也是变量名而非取值——读取方仍需在自己的环境中设置 `${VAR}`。 +> ⚠️ **CodeBuddy 是有意解析为明文的。** 它的 IDE 以 GUI 应用方式启动,不会继承你 shell 中 `export` 的变量,因此 `${VAR}` 占位符会展开为空、导致服务端 401。为此 teamai 会把解析后的 token 写入 `.codebuddy/mcp.json`。请勿提交该文件——把它加入 `.gitignore`。Cursor 与 Claude 在所有 scope 下都保留占位符,因此即便 `.cursor/mcp.json` / `.mcp.json` 被提交,里面记的也是变量名而非取值。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index f100daa..c6d2b00 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -155,7 +155,7 @@ servers: it('skips a server whose ${VAR} cannot be resolved instead of injecting it broken', async () => { // Scoped to claude at user scope — the target that resolves ${VAR} onto disk. - // Tools that pass placeholders through (cursor/codebuddy) never reach this path. + // Cursor passes placeholders through in its own syntax and never reaches this path. await writeMcpYaml(` servers: - name: needs-token @@ -291,7 +291,7 @@ servers: expect(await fse.pathExists(path.join(projectRoot, '.mcp.json'))).toBe(true); }); - it('passes a secret placeholder through to a project file, in each tool\'s own syntax', async () => { + it('writes a project secret in each tool\'s own form: placeholder for claude/cursor, plaintext for codebuddy', async () => { const projectRoot = path.join(tmpDir, 'proj2'); for (const d of ['.claude', '.cursor', '.codebuddy']) { await fse.ensureDir(path.join(projectRoot, d, 'skills')); @@ -317,11 +317,13 @@ servers: const claudeDoc = await fse.readJson(path.join(projectRoot, '.mcp.json')); expect(claudeDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${SECRET_TOKEN}'); - // CodeBuddy interpolates the bare ${VAR}, so the placeholder survives unchanged - // and the transport is keyed off `type` (not the ignored `transportType`). + // CodeBuddy's IDE is a GUI app that never inherits the user's shell exports, + // so a ${VAR} placeholder resolves to empty and 401s. We resolve to plaintext + // instead — the token is present regardless of how the tool is launched. The + // transport is keyed off `type` (not the ignored `transportType`). const buddyDoc = await fse.readJson(path.join(projectRoot, '.codebuddy', 'mcp.json')); expect(buddyDoc.mcpServers['with-secret'].type).toBe('http'); - expect(buddyDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${SECRET_TOKEN}'); + expect(buddyDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer super-secret-value'); // Cursor interpolates ${env:NAME}, so the placeholder is rewritten into that syntax. const cursorDoc = await fse.readJson(path.join(projectRoot, '.cursor', 'mcp.json')); @@ -329,8 +331,9 @@ servers: expect(cursorDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${env:SECRET_TOKEN}'); expect(cursorDoc.mcpServers['no-secret']).toBeDefined(); - // No plaintext secret is ever written to any project file. - for (const f of ['.mcp.json', '.cursor/mcp.json', '.codebuddy/mcp.json']) { + // Claude and cursor keep the secret off disk; codebuddy is the deliberate + // exception, so it is excluded from the no-plaintext check. + for (const f of ['.mcp.json', '.cursor/mcp.json']) { const raw = await fse.readFile(path.join(projectRoot, f), 'utf-8'); expect(raw).not.toContain('super-secret-value'); } diff --git a/src/resources/mcp-format.ts b/src/resources/mcp-format.ts index 96a9463..ccd3fce 100644 --- a/src/resources/mcp-format.ts +++ b/src/resources/mcp-format.ts @@ -50,7 +50,10 @@ export function supportsTransport(format: McpFormat, transport: McpTransport): b * claude expands env vars, but only in a project-scope .mcp.json. * cursor interpolates ${env:NAME} anywhere, in every scope — the renderer * rewrites our ${NAME} into that syntax. - * codebuddy interpolates the bare ${NAME} anywhere, in every scope. + * codebuddy the CLI would expand a bare ${NAME}, but the IDE runs as a GUI app + * that never inherits the user's shell exports, so the placeholder + * resolves to empty and the server 401s. We resolve to plaintext + * instead so the token is present regardless of how the tool starts. * codex names the variable instead of holding its value * (`bearer_token_env_var`, `env_http_headers`) — but those fields * only name a variable for a whole header value, so a placeholder @@ -63,7 +66,7 @@ export function supportsEnvExpansion( def?: McpServerDef, ): boolean { if (format === 'claude') return projectScope; - if (format === 'cursor' || format === 'buddy') return true; + if (format === 'cursor') return true; if (format === 'codex' && def?.transport === 'http') return codexCanNameEveryVar(def); return false; }