From 31a1890e686c9371f7a8e95e8947b2a1e38550a3 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Wed, 29 Jul 2026 13:17:41 +0800 Subject: [PATCH] feat(mcp): resolve secrets into project-scope configs for all tools Previously, project-scope MCP config files for tools that cannot expand ${VAR} themselves (Cursor, CodeBuddy) skipped any secret-bearing server, to avoid writing a resolved secret into a committed file. Teams that accept committing the secret (or gitignore the config) had no way to distribute such a server at project scope. Drop the project-scope refusal: the value is now resolved and written verbatim for every tool, in every scope. Claude project .mcp.json still passes the placeholder through, and Codex still names the env var, so those keep secrets off disk as before. Docs gain a warning to gitignore .cursor/mcp.json / .codebuddy/mcp.json or use user scope if the secret must stay out of version control. Co-Authored-By: Claude Opus 4.8 --- docs/usage-guide.md | 4 +++- docs/usage-guide.zh-CN.md | 4 +++- src/__tests__/mcp-reconcile.test.ts | 18 +++++++----------- src/mcp-reconcile.ts | 18 +++--------------- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 34d29f9..8d5de35 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d5070ec..7966844 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -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` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 8885d4e..490840d 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -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')); @@ -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; }); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index ebfc5ad..d7ba10b 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -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({