diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f33a7cf..e8df5eb 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -433,9 +433,18 @@ 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). Everywhere else the value is resolved and written verbatim into the target file (new files are created `0600`). +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: -> ⚠️ 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. +| tool | on disk | +|---|---| +| Claude (project `.mcp.json`) | `${VAR}` | +| CodeBuddy | `${VAR}` | +| Cursor | `${env:VAR}` | +| Codex | `bearer_token_env_var` / `env_http_headers` (variable name only) | + +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`). + +> ⚠️ 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. 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 fd43b9a..312584d 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -431,9 +431,18 @@ Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。归属记录在 `~/.teama **密钥**:写 `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。 -能让密钥不落盘的工具会走那条路:Claude 项目级 `.mcp.json` 保留占位符(由 Claude 展开);Codex 写 `bearer_token_env_var` / `env_http_headers`(只记变量名)。其余情况会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。 +对于自身能展开环境变量的工具,teamai 让密钥不落盘,只写入占位符,且使用该工具各自的语法: -> ⚠️ 在**项目级**下,不支持 `${VAR}` 展开的工具(Cursor、CodeBuddy)会把解析后的密钥明文写入其配置文件——该文件位于仓库内且通常会被提交。若不希望密钥进入版本控制,请把 `.cursor/mcp.json` / `.codebuddy/mcp.json` 加入 `.gitignore`,或改用**用户级**下发带密钥的 server。 +| 工具 | 落盘内容 | +|---|---| +| Claude(项目级 `.mcp.json`) | `${VAR}` | +| CodeBuddy | `${VAR}` | +| Cursor | `${env:VAR}` | +| Codex | `bearer_token_env_var` / `env_http_headers`(只记变量名) | + +其余情况——用户级的 Claude,或 Codex 无法用「整个 header 一个变量」表达的占位符——会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。 + +> ⚠️ 只有上述回退情况才会把解析后的密钥明文落盘。Cursor 与 CodeBuddy 现在在所有 scope 下都保留占位符,因此即便 `.cursor/mcp.json` / `.codebuddy/mcp.json` 被提交,里面记的也是变量名而非取值——读取方仍需在自己的环境中设置 `${VAR}`。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 0deefe1..f100daa 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -154,6 +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. + // Tools that pass placeholders through (cursor/codebuddy) never reach this path. await writeMcpYaml(` servers: - name: needs-token @@ -161,6 +163,7 @@ servers: url: https://example.com/mcp headers: Authorization: Bearer \${DEFINITELY_UNSET_TOKEN_XYZ} + tools: [claude] `); const { changes } = await reconcileMcpForConfig(teamConfig, localConfig); @@ -288,9 +291,9 @@ servers: expect(await fse.pathExists(path.join(projectRoot, '.mcp.json'))).toBe(true); }); - it('resolves a secret into a project file for tools that cannot expand ${VAR}', async () => { + it('passes a secret placeholder through to a project file, in each tool\'s own syntax', async () => { const projectRoot = path.join(tmpDir, 'proj2'); - for (const d of ['.claude', '.cursor']) { + for (const d of ['.claude', '.cursor', '.codebuddy']) { await fse.ensureDir(path.join(projectRoot, d, 'skills')); } const projectConfig = { ...localConfig, scope: 'project', projectRoot } as unknown as LocalConfig; @@ -308,21 +311,29 @@ servers: url: https://example.com/open `); - const { changes } = await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); // 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 expand ${VAR}, so the value is resolved and written verbatim. + // CodeBuddy interpolates the bare ${VAR}, so the placeholder survives unchanged + // and 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}'); + + // 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'].headers.Authorization).toBe('Bearer super-secret-value'); + 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(); - const added = changes.find((c) => c.tool === 'cursor' && c.server === 'with-secret'); - expect(added?.action).toBe('added'); + // No plaintext secret is ever written to any project file. + for (const f of ['.mcp.json', '.cursor/mcp.json', '.codebuddy/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/resources/mcp-format.ts b/src/resources/mcp-format.ts index ec28325..96a9463 100644 --- a/src/resources/mcp-format.ts +++ b/src/resources/mcp-format.ts @@ -46,18 +46,24 @@ 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. * - * Two tools manage it, by different means. Claude Code expands env vars in a - * project-scope .mcp.json. 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. + * 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 interpolates the bare ${NAME} anywhere, in every scope. + * 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. */ export function supportsEnvExpansion( format: McpFormat, projectScope: boolean, def?: McpServerDef, ): boolean { - if (format === 'claude' && projectScope) return true; + if (format === 'claude') return projectScope; + if (format === 'cursor' || format === 'buddy') return true; if (format === 'codex' && def?.transport === 'http') return codexCanNameEveryVar(def); return false; } @@ -176,15 +182,27 @@ function renderClaude(def: McpServerDef): McpJsonEntry { return e; } +/** + * Cursor interpolates `${env:NAME}`, not the bare `${NAME}` our defs carry. + * Rewrite every placeholder into cursor's syntax; a def whose vars were already + * resolved has no `${…}` left, so this is a no-op there. + */ +function toCursorEnvSyntax(s: string): string { + return s.replace(PLACEHOLDER_RE, (_whole, name: string) => `\${env:${name}}`); +} + function renderCursor(def: McpServerDef): McpJsonEntry { + const mapVals = (m: Record): Record => + Object.fromEntries(Object.entries(m).map(([k, v]) => [k, toCursorEnvSyntax(v)])); const e: McpJsonEntry = {}; if (def.transport === 'stdio') { e.command = def.command; - if (def.args?.length) e.args = def.args; - if (def.env && Object.keys(def.env).length) e.env = def.env; + if (def.args?.length) e.args = def.args.map(toCursorEnvSyntax); + if (def.env && Object.keys(def.env).length) e.env = mapVals(def.env); } else { - e.url = def.url; - if (def.headers && Object.keys(def.headers).length) e.headers = def.headers; + e.type = def.transport; + e.url = def.url ? toCursorEnvSyntax(def.url) : def.url; + if (def.headers && Object.keys(def.headers).length) e.headers = mapVals(def.headers); } return e; } @@ -196,7 +214,10 @@ function renderBuddy(def: McpServerDef): McpJsonEntry { if (def.args?.length) e.args = def.args; if (def.env && Object.keys(def.env).length) e.env = def.env; } else { - e.transportType = def.transport === 'http' ? 'streamable-http' : 'sse'; + // CodeBuddy keys the remote transport off `type`, exactly like the claude + // family — an older `transportType: "streamable-http"` is ignored, so the + // Authorization header never ships and the server 401s. + e.type = def.transport; e.url = def.url; if (def.headers && Object.keys(def.headers).length) e.headers = def.headers; } diff --git a/src/resources/mcp.ts b/src/resources/mcp.ts index e5b61e2..59b97e1 100644 --- a/src/resources/mcp.ts +++ b/src/resources/mcp.ts @@ -9,9 +9,8 @@ import { log } from '../utils/logger.js'; // ─── Schema for mcp/mcp.yaml ──────────────────────────────── // // Team-declared MCP servers. Transport names are the tool-neutral MCP spec -// names; each tool's own spelling (Claude's `type`, CodeBuddy's -// `transportType: streamable-http`, Codex's TOML table) is applied at render -// time by mcp-format.ts. +// names; each tool's own spelling (the claude/cursor/codebuddy `type` field, +// Codex's TOML table) is applied at render time by mcp-format.ts. const TeamMcpServerSchema = z .object({