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
15 changes: 3 additions & 12 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,20 +431,11 @@ Where each tool's servers land:

Codex supports `stdio` and `http`; `sse` is skipped. 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. Values resolve from the environment, then from `env/env.yaml` → `~/.teamai/env`. Unresolved variables skip the server with a hint.
**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.

Where the tool can expand env vars itself, teamai keeps the secret off disk and writes only the placeholder, in that tool's own syntax:
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.

| tool | on disk |
|---|---|
| Claude (project `.mcp.json`) | `${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, 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`).

> ⚠️ **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.
> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.cursor/mcp.json`, `.codebuddy/mcp.json`, `.codex/config.toml`) 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.

Expand Down
15 changes: 3 additions & 12 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,20 +429,11 @@ servers:

Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。

**密钥**: `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。
**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。

对于自身能展开环境变量的工具,teamai 让密钥不落盘,只写入占位符,且使用该工具各自的语法:
teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。

| 工具 | 落盘内容 |
|---|---|
| Claude(项目级 `.mcp.json`) | `${VAR}` |
| Cursor | `${env:VAR}` |
| Codex | `bearer_token_env_var` / `env_http_headers`(只记变量名) |
| CodeBuddy | 解析为明文(见下) |

其余情况——用户级的 Claude、CodeBuddy,或 Codex 无法用「整个 header 一个变量」表达的占位符——会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。

> ⚠️ **CodeBuddy 是有意解析为明文的。** 它的 IDE 以 GUI 应用方式启动,不会继承你 shell 中 `export` 的变量,因此 `${VAR}` 占位符会展开为空、导致服务端 401。为此 teamai 会把解析后的 token 写入 `.codebuddy/mcp.json`。请勿提交该文件——把它加入 `.gitignore`。Cursor 与 Claude 在所有 scope 下都保留占位符,因此即便 `.cursor/mcp.json` / `.mcp.json` 被提交,里面记的也是变量名而非取值。
> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.cursor/mcp.json`、`.codebuddy/mcp.json`、`.codex/config.toml`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。

Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。

Expand Down
49 changes: 21 additions & 28 deletions src/__tests__/mcp-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ 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.
// Cursor passes placeholders through in its own syntax and never reaches this path.
// Every tool resolves ${VAR} onto disk now, so an unresolvable var skips the
// server everywhere; scoped to claude here just to keep the assertion focused.
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('writes a project secret in each tool\'s own form: placeholder for claude/cursor, plaintext for codebuddy', async () => {
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']) {
await fse.ensureDir(path.join(projectRoot, d, 'skills'));
Expand All @@ -313,31 +313,22 @@ servers:

await reconcileMcpForConfig(teamConfig, projectConfig);

// Claude expands ${VAR} itself, so the placeholder is passed through intact.
// teamai resolves every secret to plaintext rather than relying on any tool's
// own ${VAR} expansion, which is fragile (GUI IDEs never inherit shell exports,
// so a placeholder resolves to empty and the server 401s). Each remote server
// keys its transport off `type`, not the ignored `transportType`.
const claudeDoc = await fse.readJson(path.join(projectRoot, '.mcp.json'));
expect(claudeDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${SECRET_TOKEN}');
expect(claudeDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer super-secret-value');

// 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 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['with-secret'].headers.Authorization).toBe('Bearer super-secret-value');
expect(cursorDoc.mcpServers['no-secret']).toBeDefined();

// 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');
}

delete process.env.SECRET_TOKEN;
});

Expand Down Expand Up @@ -376,9 +367,11 @@ servers:
expect(changes.some((c) => c.server === 'evil' && c.action === 'added')).toBe(false);
});

// Verified against codex-cli 0.142.5: it speaks streamable HTTP, and names the
// env var for a bearer token rather than storing the value.
it('writes an http server into codex config.toml, naming the token env var', async () => {
// Verified against codex-cli 0.142.5: it speaks streamable HTTP. Secrets are
// resolved to plaintext like every other tool — codex's env-var naming
// (`bearer_token_env_var`) is not used, so the token is present regardless of
// how codex is launched.
it('writes an http server into codex config.toml with the token resolved to plaintext', async () => {
await fse.ensureDir(path.join(homeDir, '.codex', 'skills'));
process.env.REMOTE_TOKEN = 'super-secret-value';
await writeMcpYaml(`
Expand All @@ -399,18 +392,18 @@ servers:
const toml = await fse.readFile(path.join(homeDir, '.codex', 'config.toml'), 'utf-8');

expect(toml).toContain('url = "https://example.com/mcp"');
expect(toml).toContain('bearer_token_env_var = "REMOTE_TOKEN"');
expect(toml).toContain('env_http_headers = { "X-Trace" = "TRACE_ID" }');
expect(toml).toContain('http_headers = { "X-Team" = "literal-value" }');
// All headers resolve to plaintext and land in http_headers; no env-var naming.
expect(toml).not.toContain('bearer_token_env_var');
expect(toml).not.toContain('env_http_headers');
expect(toml).toContain('"Authorization" = "Bearer super-secret-value"');
expect(toml).toContain('"X-Trace" = "trace-1"');
expect(toml).toContain('"X-Team" = "literal-value"');
expect(toml).toContain('startup_timeout_sec = 600');
// The point of naming the variable: the value never reaches the file.
expect(toml).not.toContain('super-secret-value');
expect(toml).not.toContain('trace-1');
delete process.env.REMOTE_TOKEN;
delete process.env.TRACE_ID;
});

it('resolves a codex placeholder it cannot name, such as one inside the url', async () => {
it('resolves a codex placeholder inside the url to plaintext', async () => {
await fse.ensureDir(path.join(homeDir, '.codex', 'skills'));
process.env.REGION = 'eu';
await writeMcpYaml(`
Expand Down
38 changes: 10 additions & 28 deletions src/resources/mcp-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,19 @@ export function supportsTransport(format: McpFormat, transport: McpTransport): b
* Whether the target file keeps the secret out of itself, letting the ${VAR} be
* written through verbatim rather than resolved onto disk.
*
* Each tool manages it by different means:
* 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 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
* embedded in a larger string, or one in the URL, still has to be
* resolved.
* Always false: teamai resolves every secret to plaintext before writing it.
* Env-var passthrough was tried per tool, but each tool expands variables under
* different, fragile conditions — most decisively, IDEs launched from the GUI
* never inherit the user's shell exports, so a ${VAR} placeholder resolves to
* empty and the server 401s. Resolving to plaintext makes the token present no
* matter how the tool is started, at the cost of the value landing on disk (new
* files are created 0600; users should gitignore project-scope MCP configs).
*/
export function supportsEnvExpansion(
format: McpFormat,
projectScope: boolean,
def?: McpServerDef,
_format: McpFormat,
_projectScope: boolean,
_def?: McpServerDef,
): boolean {
if (format === 'claude') return projectScope;
if (format === 'cursor') return true;
if (format === 'codex' && def?.transport === 'http') return codexCanNameEveryVar(def);
return false;
}

Expand All @@ -76,15 +67,6 @@ const BEARER_PLACEHOLDER_RE = /^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
/** A header whose entire value is one placeholder. */
const WHOLE_PLACEHOLDER_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;

function codexCanNameEveryVar(def: McpServerDef): boolean {
if (def.url?.includes('${')) return false;
for (const value of Object.values(def.headers ?? {})) {
if (!value.includes('${')) continue;
if (!BEARER_PLACEHOLDER_RE.test(value) && !WHOLE_PLACEHOLDER_RE.test(value)) return false;
}
return true;
}

interface CodexHeaderPlan {
bearerTokenEnvVar?: string;
envHttpHeaders: Record<string, string>;
Expand Down
Loading