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
13 changes: 11 additions & 2 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand Down
29 changes: 20 additions & 9 deletions src/__tests__/mcp-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,16 @@ 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
transport: http
url: https://example.com/mcp
headers:
Authorization: Bearer \${DEFINITELY_UNSET_TOKEN_XYZ}
tools: [claude]
`);

const { changes } = await reconcileMcpForConfig(teamConfig, localConfig);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
});
Expand Down
43 changes: 32 additions & 11 deletions src/resources/mcp-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string, string>): Record<string, string> =>
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;
}
Expand All @@ -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;
}
Expand Down
5 changes: 2 additions & 3 deletions src/resources/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading