Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 标为待批准,需在交互式会话中确认一次。

Expand Down
17 changes: 10 additions & 7 deletions src/__tests__/mcp-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'));
Expand All @@ -317,20 +317,23 @@ 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'));
expect(cursorDoc.mcpServers['with-secret'].type).toBe('http');
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');
}
Expand Down
7 changes: 5 additions & 2 deletions src/resources/mcp-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
Loading