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
4 changes: 3 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,9 @@ Codex supports `stdio` and `http`; `sse` is skipped. Ownership is tracked in `~/

**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.

Where the tool can keep secrets off disk, teamai does: Claude project `.mcp.json` keeps the placeholder (Claude expands it); Codex writes `bearer_token_env_var` / `env_http_headers` (variable name only). Elsewhere the value is resolved into a `0600` file. In project scope, tools that cannot expand `${VAR}` skip secret-bearing servers instead of writing plaintext into a committed file.
Where the tool can keep secrets off disk, teamai does: Claude project `.mcp.json` keeps the placeholder (Claude expands it); Codex writes `bearer_token_env_var` / `env_http_headers` (variable name only). Everywhere else the value is resolved and written verbatim into the target file (new files are created `0600`).

> ⚠️ In **project scope**, tools that cannot expand `${VAR}` (Cursor, CodeBuddy) get the resolved value written into their config file — which lives in the repo and is typically committed. Add `.cursor/mcp.json` / `.codebuddy/mcp.json` to `.gitignore`, or distribute secret-bearing servers at **user scope**, if you do not want the secret in version control.

Claude Code may show project `.mcp.json` servers as pending approval until you accept them once in an interactive session.

Expand Down
4 changes: 3 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,9 @@ Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。归属记录在 `~/.teama

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

能让密钥不落盘的工具会走那条路:Claude 项目级 `.mcp.json` 保留占位符(由 Claude 展开);Codex 写 `bearer_token_env_var` / `env_http_headers`(只记变量名)。其余情况解析后写入 `0600` 文件。项目级下,不支持 `${VAR}` 展开的工具会跳过带密钥的 server,避免明文进 git。
能让密钥不落盘的工具会走那条路:Claude 项目级 `.mcp.json` 保留占位符(由 Claude 展开);Codex 写 `bearer_token_env_var` / `env_http_headers`(只记变量名)。其余情况会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。

> ⚠️ 在**项目级**下,不支持 `${VAR}` 展开的工具(Cursor、CodeBuddy)会把解析后的密钥明文写入其配置文件——该文件位于仓库内且通常会被提交。若不希望密钥进入版本控制,请把 `.cursor/mcp.json` / `.codebuddy/mcp.json` 加入 `.gitignore`,或改用**用户级**下发带密钥的 server。

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

Expand Down
18 changes: 7 additions & 11 deletions src/__tests__/mcp-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ servers:
expect(await fse.pathExists(path.join(projectRoot, '.mcp.json'))).toBe(true);
});

it('refuses to write a secret in plaintext into a committed project file', async () => {
it('resolves a secret into a project file for tools that cannot expand ${VAR}', async () => {
const projectRoot = path.join(tmpDir, 'proj2');
for (const d of ['.claude', '.cursor']) {
await fse.ensureDir(path.join(projectRoot, d, 'skills'));
Expand All @@ -313,21 +313,17 @@ servers:
// Claude expands ${VAR} itself, so the placeholder is passed through intact.
const claudeDoc = await fse.readJson(path.join(projectRoot, '.mcp.json'));
expect(claudeDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${SECRET_TOKEN}');
const claudeRaw = await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8');
expect(claudeRaw).not.toContain('super-secret-value');

// Cursor cannot, so the server is dropped rather than resolved to plaintext.
// Cursor cannot expand ${VAR}, so the value is resolved and written verbatim.
const cursorDoc = await fse.readJson(path.join(projectRoot, '.cursor', 'mcp.json'));
expect(cursorDoc.mcpServers['with-secret']).toBeUndefined();
expect(cursorDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer super-secret-value');
expect(cursorDoc.mcpServers['no-secret']).toBeDefined();

const skipped = changes.find((c) => c.tool === 'cursor' && c.server === 'with-secret');
expect(skipped?.action).toBe('skipped');
expect(skipped?.reason).toMatch(/plaintext/);
const added = changes.find((c) => c.tool === 'cursor' && c.server === 'with-secret');
expect(added?.action).toBe('added');

// Belt and braces: the secret must not appear anywhere under the project.
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
18 changes: 3 additions & 15 deletions src/mcp-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,24 +341,12 @@ export async function reconcileMcpForConfig(

// Pass ${VAR} through where the tool expands it itself, so the secret
// never lands on disk; otherwise resolve and require every var to exist.
// A resolved value is written verbatim into the target file, including
// project-scope files that get committed — the team has opted into that
// by declaring the server with a ${VAR} a tool cannot expand itself.
const passthrough = supportsEnvExpansion(target.format, target.projectScope, raw);
let def = raw;
if (!passthrough) {
// A project-scope file lives in the repo and gets committed. Resolving a
// placeholder for a tool that cannot expand ${VAR} would put the secret
// into version control, so refuse rather than leak it.
const referenced = referencedVars(raw);
if (target.projectScope && referenced.length > 0) {
changes.push({
tool: target.tool,
server: raw.name,
action: 'skipped',
reason:
`${target.tool} cannot expand \${VAR}, so ${referenced.join(', ')} would be ` +
`written in plaintext to a committed file — distribute this server at user scope instead`,
});
continue;
}
const { def: resolved, missing } = resolvePlaceholders(raw, vars);
if (missing.length > 0) {
changes.push({
Expand Down
Loading