From ab34ab259598345d0abe8a2f716f2e4a36899435 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 28 Sep 2026 15:18:54 +0200 Subject: [PATCH 01/85] feat(mcp): keep project MCP configs with resolved values out of git (#882) A project-scope MCP config that carries a resolved ${VAR} sat untracked and unignored in the business repo, one `git add -A` from committing the token. After the reconcile writes such a file and git would track it, teamai lists its path in the clone's .git/info/exclude inside a marked block (resolved via `git rev-parse --git-path`, so linked worktrees and submodules work). The committed .gitignore is never touched; an ignored path or a config with no resolved value adds nothing; dry runs write nothing. Project-scope uninstall removes only teamai's block, and doctor reports such a file git would still commit. The hook sits after the appliers in reconcileMcpForConfig, outside desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with #880. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/doctor-mcp-delivery.test.ts | 47 ++++++++++ src/__tests__/mcp-reconcile.test.ts | 79 ++++++++++++++++ src/__tests__/uninstall.test.ts | 31 ++++++ src/doctor-delivery.ts | 40 ++++++++ src/doctor.ts | 2 + src/mcp-git-exclude.ts | 109 ++++++++++++++++++++++ src/mcp-reconcile.ts | 8 +- src/uninstall.ts | 7 ++ 10 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 src/mcp-git-exclude.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index e6f430ff9..55aa3ba1f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret — add them to `.gitignore` and never commit them. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. When teamai writes such a file and git would track it, it lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it); the committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block. `teamai doctor` reports such a file git would still commit — for example one already tracked: `git rm --cached` it and rotate the token. 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 50d79ac2b..a3d684ccc 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。teamai 写入这类文件且 git 会跟踪它时,会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件);不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块。`teamai doctor` 会报告 git 仍会提交的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index b170dc5a9..a83432c27 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fse from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -229,4 +230,50 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await fse.readFile(file, 'utf8')).toBe(before); }); + + describe('project MCP config holding a resolved value (#882)', () => { + const NAME = 'Project MCP configs with resolved values are kept out of git'; + let projectRoot: string; + + beforeEach(async () => { + projectRoot = path.join(tempDir, 'business-repo'); + await fse.ensureDir(path.join(projectRoot, '.claude', 'skills')); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + Object.assign(localConfig, { scope: 'project', projectRoot }); + teamConfig.toolPaths = { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' } }; + await writeTeamMcp( + 'servers:\n - name: jira\n transport: http\n url: https://jira.example/mcp\n' + + ' headers:\n Authorization: "Bearer ${JIRA_TOKEN}"\n', + ); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer t0ken' } } }, + }); + }); + + async function excludeCheck(): Promise { + return (await checks()).find((c) => c.name === NAME); + } + + it('fails while git would track the file, and names it', async () => { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); + expect(check.fix).toContain('teamai pull'); + }); + + it('passes once git ignores the file', async () => { + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(true); + }); + + it('emits no check when the installed servers carry no resolved value', async () => { + await writeTeamMcp('servers:\n - name: jira\n transport: http\n url: https://jira.example/mcp\n'); + + expect(await excludeCheck()).toBeUndefined(); + }); + }); }); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 9ad27eb4f..2397f2234 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { execFileSync } from 'node:child_process'; vi.mock('../utils/logger.js', () => ({ log: { @@ -864,6 +865,84 @@ servers: delete process.env.SECRET_TOKEN; }); + describe('project MCP configs holding a resolved value stay out of git (#882)', () => { + let projectRoot: string; + let projectConfig: LocalConfig; + const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { cwd, encoding: 'utf-8' }); + const excludeOf = (root: string): Promise => + fse.readFile(path.join(root, '.git', 'info', 'exclude'), 'utf-8'); + const withSecret = ` +servers: + - name: with-secret + transport: http + url: https://example.com/mcp + headers: + Authorization: Bearer \${SECRET_TOKEN} +`; + + beforeEach(async () => { + projectRoot = path.join(tmpDir, 'business-repo'); + for (const d of ['.claude', '.cursor']) await fse.ensureDir(path.join(projectRoot, d, 'skills')); + git(projectRoot, 'init', '-q'); + projectConfig = { ...localConfig, scope: 'project', projectRoot } as unknown as LocalConfig; + vi.stubEnv('SECRET_TOKEN', 'super-secret-value'); + }); + + it('adds every such config to .git/info/exclude once, inside a teamai block', async () => { + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig, { force: true }); + + const exclude = await excludeOf(projectRoot); + expect(exclude.match(/^\/\.mcp\.json$/gm)).toHaveLength(1); + expect(exclude.match(/^\/\.cursor\/mcp\.json$/gm)).toHaveLength(1); + expect(exclude).toContain('# [teamai:mcp-exclude:start]'); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/ (\.mcp\.json|\.cursor\/)/); + expect(await fse.pathExists(path.join(projectRoot, '.gitignore'))).toBe(false); + }); + + it('adds nothing for a config that carries no resolved value', async () => { + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + + it('adds nothing for a path git already ignores, and leaves .gitignore as it is', async () => { + await fse.writeFile(path.join(projectRoot, '.gitignore'), '.mcp.json\n.cursor/\n'); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + expect(await fse.readFile(path.join(projectRoot, '.gitignore'), 'utf-8')).toBe('.mcp.json\n.cursor/\n'); + }); + + it('writes to the repository git dir from a linked worktree', async () => { + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = path.join(tmpDir, 'business-wt'); + git(projectRoot, 'worktree', 'add', '-q', worktree); + for (const d of ['.claude', '.cursor']) await fse.ensureDir(path.join(worktree, d, 'skills')); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, projectRoot: worktree } as LocalConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(git(worktree, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/ (\.mcp\.json|\.cursor\/)/); + }); + + it('leaves .git/info/exclude alone on a dry run', async () => { + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, projectConfig, { dryRun: true }); + + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + }); + it('skips tools that are not installed', async () => { await writeMcpYaml(` servers: diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 106fe4988..b0d26dfb1 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; import { shipped, shippedSkillDigestsMock } from './helpers/shipped-skills.js'; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -696,6 +697,36 @@ describe('uninstall', () => { expect(after.mcpServers['my-own']).toEqual({ command: 'my-server' }); }); + it('project-scope uninstall removes only the teamai block from .git/info/exclude (#882)', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + const excludeFile = path.join(projectRoot, '.git', 'info', 'exclude'); + await fse.writeFile(excludeFile, [ + '# my own', + 'scratch/', + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/.mcp.json', + '# [teamai:mcp-exclude:end]', + '*.local', + '', + ].join('\n')); + + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe('# my own\nscratch/\n*.local\n'); + }); + it('移除 OpenClaw 系 agent 的 HOOK.md 目录(无 settings 路径)', async () => { const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 885f142b2..c9bf8990f 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -499,6 +499,46 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise { + const { localConfig, teamConfig } = ctx; + if (!teamConfig || localConfig.scope !== 'project' || localConfig.repo.kind === 'http') return []; + + const { resolveMcpTargets, mcpTargetExcluded, installedMcpEntries } = await import('./mcp-reconcile.js'); + const { carriesResolvedValue, gitWouldTrack } = await import('./mcp-git-exclude.js'); + const { mcpEntryReader, teamMcpToDef } = await import('./resources/mcp.js'); + const { resolveEntriesFor } = await import('./namespaced-entries.js'); + + const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); + if (resolution.kind === 'failed') return []; + const teamDefs = resolution.entries.map((entry) => teamMcpToDef(entry.entry)); + + const tracked: string[] = []; + let holding = 0; + for (const target of await resolveMcpTargets(teamConfig, localConfig)) { + if (mcpTargetExcluded(localConfig, target)) continue; + const installed = await installedMcpEntries(target); + if (!installed || !carriesResolvedValue(target, teamDefs, installed.keys())) continue; + holding += 1; + if (await gitWouldTrack(target.file)) tracked.push(target.file); + } + if (holding === 0) return []; + + return [{ + name: 'Project MCP configs with resolved values are kept out of git', + source: 'local', + check: async () => tracked.length === 0, + fix: `${tracked.join(', ')} hold MCP variables resolved to plaintext, and git would commit them. ` + + 'Run `teamai pull` to list them in .git/info/exclude. If git already tracks one, run ' + + '`git rm --cached ` and rotate the values it held.', + }]; +} + /** * Env, hook and MCP entries carrying a key to fix: the per-entry `roles:` / * `projects:` keys that namespace files replace (#707), or a key the entry's diff --git a/src/doctor.ts b/src/doctor.ts index b6fdd483d..0c2c85903 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -26,6 +26,7 @@ import { buildAgentsDeliveryChecks, buildNamespaceNotes, buildMcpDeliveryChecks, + buildMcpGitExcludeCheck, buildEnvDeliveryCheck, buildEntryResolutionChecks, buildEntryScopeKeyCheck, @@ -457,6 +458,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto ...(stage === 'doctor' ? await buildRulesDeliveryChecks(ctx) : []), ...(stage === 'doctor' ? await buildAgentsDeliveryChecks(ctx) : []), ...await buildMcpDeliveryChecks(ctx), + ...await buildMcpGitExcludeCheck(ctx), ...await buildDocsCheck(ctx), ...await buildEnvDeliveryCheck(ctx), ...await buildEntryResolutionChecks(ctx), diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts new file mode 100644 index 000000000..66278bd7c --- /dev/null +++ b/src/mcp-git-exclude.ts @@ -0,0 +1,109 @@ +import path from 'node:path'; +import fse from 'fs-extra'; +import type { McpServerDef } from './types.js'; +import type { McpTarget } from './mcp-reconcile.js'; +import { referencedVars, supportsEnvExpansion } from './resources/mcp-format.js'; +import { execCommand } from './utils/exec.js'; +import { pathExists, readFileSafe } from './utils/fs.js'; +import { log } from './utils/logger.js'; + +// ─── Project MCP configs and git ───────────────────────────── +// +// A project-scope MCP config that holds a resolved `${VAR}` sits in the +// business repo's working tree with the value in plaintext, and one +// `git add -A` commits it (#882). teamai lists such a file in the clone's own +// `.git/info/exclude`, inside a block it owns: local to the clone, nothing +// committed, and the team's `.gitignore` never touched. + +export const MCP_EXCLUDE_START = '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values'; +export const MCP_EXCLUDE_END = '# [teamai:mcp-exclude:end]'; + +/** + * Whether `target`'s file carries a value teamai resolved from a `${VAR}`: a + * project-scope file holding one of `names` whose definition references a + * variable the tool does not expand itself. + */ +export function carriesResolvedValue( + target: McpTarget, + teamDefs: McpServerDef[], + names: Iterable, +): boolean { + if (!target.projectScope) return false; + const present = new Set(names); + return teamDefs.some((def) => present.has(def.name) + && referencedVars(def).length > 0 + && !supportsEnvExpansion(target.format, target.projectScope, def)); +} + +/** The `info/exclude` git reads for `dir`'s checkout (worktrees and submodules included), and `dir`'s path from its root. */ +async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; prefix: string } | null> { + const result = await execCommand('git', ['rev-parse', '--show-prefix', '--git-path', 'info/exclude'], { cwd: dir, timeoutMs: 10_000 }) + .catch(() => null); + if (!result || result.code !== 0) return null; + const [prefix = '', gitPath = ''] = result.stdout.split(/\r?\n/); + if (!gitPath) return null; + return { excludeFile: path.resolve(dir, gitPath), prefix }; +} + +/** + * Whether git would put `file` in a commit: in a repository, and tracked or + * untracked without an ignore rule. Read-only. + */ +export async function gitWouldTrack(file: string): Promise { + const result = await execCommand('git', ['check-ignore', '-q', '--', path.basename(file)], { cwd: path.dirname(file), timeoutMs: 10_000 }) + .catch(() => null); + // 0: ignored. 1: not ignored (or tracked). 128: not a repository, or git failed. + return result?.code === 1; +} + +/** teamai's block and what surrounds it; null without both markers, so a damaged block never takes the member's lines with it. */ +function splitBlock(content: string): { before: string; patterns: string[]; after: string } | null { + const start = content.indexOf(MCP_EXCLUDE_START); + const endAt = start === -1 ? -1 : content.indexOf(MCP_EXCLUDE_END, start); + if (endAt === -1) return null; + const patterns = content.slice(start + MCP_EXCLUDE_START.length, endAt) + .split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + const after = content.slice(endAt + MCP_EXCLUDE_END.length).replace(/^\r?\n/, ''); + return { before: content.slice(0, start), patterns, after }; +} + +/** + * Add `file` to its repository's `.git/info/exclude` when git would otherwise + * track it. Idempotent; a path already ignored, or outside any repository, + * adds nothing. A failure warns rather than failing the sync that wrote the file. + */ +export async function excludeFromGit(file: string): Promise { + if (!await pathExists(file) || !await gitWouldTrack(file)) return; + const location = await gitExcludeFile(path.dirname(file)); + if (!location) return; + const { excludeFile } = location; + // Anchored at the working tree root, glob characters escaped. + const pattern = `/${location.prefix}${path.basename(file)}`.replace(/[\\*?[\]!#]/g, '\\$&'); + try { + const content = (await readFileSafe(excludeFile)) ?? ''; + const block = splitBlock(content); + if (block?.patterns.includes(pattern)) return; + const head = block ? block.before : content; + const patterns = [...(block?.patterns ?? []), pattern]; + const body = [MCP_EXCLUDE_START, ...patterns, MCP_EXCLUDE_END].join('\n'); + const sep = head === '' || head.endsWith('\n') ? '' : '\n'; + await fse.outputFile(excludeFile, `${head}${sep}${body}\n${block?.after ?? ''}`); + log.debug(`Added ${pattern} to ${excludeFile}`); + } catch (e) { + log.warn( + `${file} holds a resolved MCP variable, and adding it to ${excludeFile} failed: ${e instanceof Error ? e.message : String(e)}. ` + + `Add \`${pattern}\` to that file yourself so git does not commit the value.`, + ); + } +} + +/** Remove teamai's block from the `.git/info/exclude` of the repository at `root`. */ +export async function removeMcpGitExclude(root: string): Promise { + const location = await gitExcludeFile(root); + if (!location) return false; + const content = await readFileSafe(location.excludeFile); + const block = content === null ? null : splitBlock(content); + if (!block) return false; + await fse.writeFile(location.excludeFile, block.before + block.after); + return true; +} diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 5507906ec..40604dd5f 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -44,6 +44,7 @@ import { import { log } from './utils/logger.js'; import { loadProjectMcpManifest } from './utils/mcp-manifest.js'; import { isOnPath, SAFE_BIN_RE, type LookPathOptions } from './utils/lookpath.js'; +import { carriesResolvedValue, excludeFromGit } from './mcp-git-exclude.js'; // ─── Reconcile engine ──────────────────────────────────────── // @@ -196,7 +197,7 @@ function requirementsMet(def: McpServerDef, lookPath?: LookPathOptions): string // ─── Tool targeting ────────────────────────────────────────── -interface McpTarget { +export interface McpTarget { tool: string; format: McpFormat; /** Absolute path of the config file to edit. */ @@ -582,6 +583,11 @@ export async function reconcileMcpForConfig( if (nextRecords.length > 0) manifest[manifestKey] = nextRecords; else delete manifest[manifestKey]; + + // A resolved ${VAR} in a project file is plaintext in the business repo. + if (!removeAll && !options.dryRun && carriesResolvedValue(target, teamDefs, nextRecords.map((r) => r.name))) { + await excludeFromGit(target.file); + } } if (!options.dryRun && wrote) { diff --git a/src/uninstall.ts b/src/uninstall.ts index a8d9de59f..5805dbfc4 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1230,6 +1230,13 @@ export async function uninstall(opts: UninstallOptions): Promise { removedTotal += changes.filter((c) => c.action === 'removed').length; } if (removedTotal > 0) log.info(`Removed ${removedTotal} teamai-managed MCP server(s)`); + // Every worktree shares one info/exclude, so it goes once they are all clean. + if (localConfig.scope === 'project' && localConfig.projectRoot) { + const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); + if (await removeMcpGitExclude(localConfig.projectRoot)) { + log.info('Removed teamai\'s MCP config entries from .git/info/exclude'); + } + } } catch (e) { log.warn(`Failed to remove MCP servers: ${(e as Error).message}`); } From 371b6d4446eaf6f8a0b2c26aafcf3904f0f2d1ad Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 28 Sep 2026 15:25:36 +0200 Subject: [PATCH 02/85] fix(uninstall): count the .git/info/exclude block in the removal plan (#882) The plan now records whether the project's .git/info/exclude holds teamai's MCP config block (gitExcludeBlock). It counts toward isPlanEmpty, is listed in the summary and dry run, and gates the removal, so a plan whose only teamai leftover is the block removes it instead of reporting "Nothing to uninstall". --- src/__tests__/uninstall.test.ts | 34 +++++++++++++++++++++++++++++++++ src/mcp-git-exclude.ts | 21 +++++++++++++++----- src/uninstall.ts | 19 +++++++++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index b0d26dfb1..2eb92771c 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -727,6 +727,40 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe('# my own\nscratch/\n*.local\n'); }); + it('project-scope uninstall removes the .git/info/exclude block when it is all teamai left (#882)', async () => { + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + const projectRoot = path.join(tmpDir, 'business-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.ensureDir(projectRoot); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + const excludeFile = path.join(projectRoot, '.git', 'info', 'exclude'); + await fse.writeFile(excludeFile, [ + 'scratch/', + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/.mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n')); + + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + const { log } = await import('../utils/logger.js'); + vi.mocked(log.info).mockClear(); + + await uninstall({ force: true }); + + expect(log.info).not.toHaveBeenCalledWith('Nothing to uninstall'); + expect(await fse.readFile(excludeFile, 'utf8')).toBe('scratch/\n'); + }); + it('移除 OpenClaw 系 agent 的 HOOK.md 目录(无 settings 路径)', async () => { const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 66278bd7c..fb0edd118 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -97,13 +97,24 @@ export async function excludeFromGit(file: string): Promise { } } -/** Remove teamai's block from the `.git/info/exclude` of the repository at `root`. */ -export async function removeMcpGitExclude(root: string): Promise { +/** teamai's block in the `.git/info/exclude` of the repository at `root`, with the file holding it. */ +async function readBlock(root: string): Promise<{ excludeFile: string; block: NonNullable> } | null> { const location = await gitExcludeFile(root); - if (!location) return false; + if (!location) return null; const content = await readFileSafe(location.excludeFile); const block = content === null ? null : splitBlock(content); - if (!block) return false; - await fse.writeFile(location.excludeFile, block.before + block.after); + return block ? { excludeFile: location.excludeFile, block } : null; +} + +/** Whether the `.git/info/exclude` of the repository at `root` holds teamai's block. */ +export async function hasMcpGitExclude(root: string): Promise { + return await readBlock(root) !== null; +} + +/** Remove teamai's block from the `.git/info/exclude` of the repository at `root`. */ +export async function removeMcpGitExclude(root: string): Promise { + const found = await readBlock(root); + if (!found) return false; + await fse.writeFile(found.excludeFile, found.block.before + found.block.after); return true; } diff --git a/src/uninstall.ts b/src/uninstall.ts index 5805dbfc4..5412cc6e4 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -113,6 +113,8 @@ interface RemovalPlan { shellProfiles: string[]; /** Docs directory (null if doesn't exist). */ docsDir: string | null; + /** Whether the project's .git/info/exclude holds teamai's MCP config block (#882). */ + gitExcludeBlock: boolean; /** The .teamai home directory path. */ teamaiHome: string; /** Whether teamaiHome exists on disk. */ @@ -619,6 +621,7 @@ async function buildRemovalPlan( mcpServers: [], shellProfiles: [], docsDir: null, + gitExcludeBlock: false, teamaiHome, teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], @@ -714,6 +717,13 @@ async function buildRemovalPlan( if (await pathExists(docsDir)) { plan.docsDir = docsDir; } + + // (g) teamai's block in the project's .git/info/exclude (#882). It counts on + // its own: a clone whose other resources are gone still gets it removed. + if (localConfig.scope === 'project' && localConfig.projectRoot) { + const { hasMcpGitExclude } = await import('./mcp-git-exclude.js'); + plan.gitExcludeBlock = await hasMcpGitExclude(localConfig.projectRoot); + } } return plan; @@ -736,6 +746,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.mcpServers.length === 0 && plan.shellProfiles.length === 0 && plan.docsDir === null && + !plan.gitExcludeBlock && !plan.teamaiHomeExists ); } @@ -850,6 +861,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } + if (plan.gitExcludeBlock) { + console.log(' Git exclude entries for MCP configs:'); + console.log(' teamai\'s block in .git/info/exclude'); + console.log(''); + } + if (plan.teamaiHomeExists) { console.log(' TeamAI home directory:'); console.log(` ${plan.teamaiHome}/`); @@ -1231,7 +1248,7 @@ export async function uninstall(opts: UninstallOptions): Promise { } if (removedTotal > 0) log.info(`Removed ${removedTotal} teamai-managed MCP server(s)`); // Every worktree shares one info/exclude, so it goes once they are all clean. - if (localConfig.scope === 'project' && localConfig.projectRoot) { + if (plan.gitExcludeBlock && localConfig.projectRoot) { const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); if (await removeMcpGitExclude(localConfig.projectRoot)) { log.info('Removed teamai\'s MCP config entries from .git/info/exclude'); From ec29e3419c8bf9a5c042c07d6dd1cb2ed805df54 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 28 Sep 2026 18:19:10 +0200 Subject: [PATCH 03/85] fix(mcp): keep the exclude block until every MCP config is clean (#882) - uninstall keeps a repository's .git/info/exclude block while a config in it could not be parsed and still holds teamai servers, and warns - uninstall finds and removes the block in nested repositories holding an MCP config, across every worktree - the block opens at the last start marker, so an orphaned start never pairs with a later block's end and takes the member's lines - doctor counts only servers the ownership manifest records, not a member's own server under a team name --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/doctor-mcp-delivery.test.ts | 11 ++- src/__tests__/mcp-git-exclude.test.ts | 36 ++++++++++ src/__tests__/uninstall.test.ts | 76 +++++++++++++++++++- src/doctor-delivery.ts | 13 +++- src/mcp-git-exclude.ts | 49 +++++++------ src/mcp-reconcile.ts | 14 ++-- src/uninstall.ts | 87 +++++++++++++++-------- 9 files changed, 228 insertions(+), 62 deletions(-) create mode 100644 src/__tests__/mcp-git-exclude.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 55aa3ba1f..7e0f6de34 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. When teamai writes such a file and git would track it, it lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it); the committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block. `teamai doctor` reports such a file git would still commit — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. When teamai writes such a file and git would track it, it lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it); the committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block — unless a config it could not parse still holds teamai's servers, in which case it keeps the block and warns. `teamai doctor` reports such a file git would still commit — for example one already tracked: `git rm --cached` it and rotate the token. 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 a3d684ccc..dd56a6560 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。teamai 写入这类文件且 git 会跟踪它时,会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件);不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块。`teamai doctor` 会报告 git 仍会提交的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。teamai 写入这类文件且 git 会跟踪它时,会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件);不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块——若某个配置无法解析、仍含 teamai 管理的 server,则保留该块并给出警告。`teamai doctor` 会报告 git 仍会提交的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index a83432c27..5f4c0893d 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -20,7 +20,7 @@ vi.mock('../utils/logger.js', () => ({ import { loadLocalConfig, loadTeamConfig } from '../config.js'; import { buildChecks, resolveDoctorContext, type Check } from '../doctor.js'; -import type { LocalConfig, TeamaiConfig } from '../types.js'; +import { getDataHome, managedMcpManifestKey, managedMcpManifestPath, type LocalConfig, type TeamaiConfig } from '../types.js'; /** * The MCP half of the delivery check (#624). A server lands as an entry inside @@ -248,6 +248,9 @@ describe('doctor — MCP servers delivered on disk', () => { await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer t0ken' } } }, }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], + }); }); async function excludeCheck(): Promise { @@ -270,6 +273,12 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await check.check()).toBe(true); }); + it('emits no check when the server of that name is the member\'s own, not teamai\'s', async () => { + await fse.remove(managedMcpManifestPath(getDataHome(localConfig), projectRoot)); + + expect(await excludeCheck()).toBeUndefined(); + }); + it('emits no check when the installed servers carry no resolved value', async () => { await writeTeamMcp('servers:\n - name: jira\n transport: http\n url: https://jira.example/mcp\n'); diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts new file mode 100644 index 000000000..d442041f1 --- /dev/null +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +vi.mock('../utils/logger.js', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), dim: vi.fn() }, +})); + +import { MCP_EXCLUDE_START, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; + +describe('teamai block in .git/info/exclude (#882)', () => { + let repo: string; + let excludeFile: string; + + beforeEach(async () => { + repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-mcp-exclude-')); + execFileSync('git', ['init', '-q'], { cwd: repo }); + excludeFile = path.join(repo, '.git', 'info', 'exclude'); + }); + + afterEach(async () => { + await fse.remove(repo); + }); + + it('never takes the member\'s lines when a start marker has lost its end marker', async () => { + await fse.writeFile(excludeFile, `${MCP_EXCLUDE_START}\n/old.json\nscratch/\n`); + await fse.writeJson(path.join(repo, '.mcp.json'), {}); + + await excludeFromGit(path.join(repo, '.mcp.json')); + expect(await removeMcpGitExclude(excludeFile)).toBe(true); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(`${MCP_EXCLUDE_START}\n/old.json\nscratch/\n`); + }); +}); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 2eb92771c..b43c580ef 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -48,7 +48,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { uninstall } from '../uninstall.js'; -import { TeamaiConfigSchema } from '../types.js'; +import { TeamaiConfigSchema, getDataHome, managedMcpManifestKey, managedMcpManifestPath } from '../types.js'; import { ModelProfileSchema, resolveProfile } from '../models/profile.js'; import { switchModelProfile } from '../models/switch.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; @@ -761,6 +761,80 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe('scratch/\n'); }); + it('project-scope uninstall keeps the .git/info/exclude block while a config still holds teamai servers (#882)', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.ensureDir(path.join(projectRoot, '.claude', 'skills')); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + // Hand-edited into invalid JSON: uninstall cannot take the resolved token out. + await fse.writeFile(path.join(projectRoot, '.mcp.json'), '{ "mcpServers": { "jira": { "headers": { "Authorization": "Bearer t0ken" } } },\n'); + const excludeFile = path.join(projectRoot, '.git', 'info', 'exclude'); + const block = [ + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/.mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n'); + await fse.writeFile(excludeFile, block); + + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], + }); + const teamConfig = makeTeamConfig({ + toolPaths: { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' } }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept teamai's block in ${await fse.realpath(excludeFile)}`)); + }); + + it('project-scope uninstall removes the block from a nested repository holding an MCP config (#882)', async () => { + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + const projectRoot = path.join(tmpDir, 'business-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const cursorDir = path.join(projectRoot, '.cursor'); + await fse.ensureDir(path.join(cursorDir, 'skills')); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + execFileSync('git', ['init', '-q'], { cwd: cursorDir }); + const excludeFile = path.join(cursorDir, '.git', 'info', 'exclude'); + await fse.writeFile(excludeFile, [ + 'scratch/', + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n')); + + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + const teamConfig = makeTeamConfig({ + toolPaths: { cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' } }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe('scratch/\n'); + }); + it('移除 OpenClaw 系 agent 的 HOOK.md 目录(无 settings 路径)', async () => { const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index c9bf8990f..1e94bd21b 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import fs from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; import { expandHome, listFilesRecursive, pathExists, readFileSafe } from './utils/fs.js'; -import { getDataHome, getMcpSharing, isAgentExcluded } from './types.js'; -import type { DeliveryTarget, LocalConfig, ResourceItem, TeamaiConfig } from './types.js'; +import { getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey } from './types.js'; +import type { DeliveryTarget, LocalConfig, ManagedMcpManifest, ResourceItem, TeamaiConfig } from './types.js'; import type { EntryResolution, EntryType } from './namespaced-entries.js'; import { splitFrontmatter } from './utils/frontmatter.js'; import type { ResourceHandler } from './resources/base.js'; @@ -507,16 +507,19 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise { const { localConfig, teamConfig } = ctx; - if (!teamConfig || localConfig.scope !== 'project' || localConfig.repo.kind === 'http') return []; + const { projectRoot } = localConfig; + if (!teamConfig || localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return []; const { resolveMcpTargets, mcpTargetExcluded, installedMcpEntries } = await import('./mcp-reconcile.js'); const { carriesResolvedValue, gitWouldTrack } = await import('./mcp-git-exclude.js'); const { mcpEntryReader, teamMcpToDef } = await import('./resources/mcp.js'); const { resolveEntriesFor } = await import('./namespaced-entries.js'); + const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); if (resolution.kind === 'failed') return []; const teamDefs = resolution.entries.map((entry) => teamMcpToDef(entry.entry)); + let manifest: ManagedMcpManifest | undefined; const tracked: string[] = []; let holding = 0; @@ -524,6 +527,10 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise record.name); + if (!carriesResolvedValue(target, teamDefs, owned.filter((name) => installed.has(name)))) continue; holding += 1; if (await gitWouldTrack(target.file)) tracked.push(target.file); } diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index fb0edd118..82e5fb249 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -42,7 +42,9 @@ async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; prefi if (!result || result.code !== 0) return null; const [prefix = '', gitPath = ''] = result.stdout.split(/\r?\n/); if (!gitPath) return null; - return { excludeFile: path.resolve(dir, gitPath), prefix }; + // Real path, so one repository reached through a symlink (macOS /var) is one file. + const base = await fse.realpath(dir).catch(() => dir); + return { excludeFile: path.resolve(base, gitPath), prefix }; } /** @@ -56,9 +58,13 @@ export async function gitWouldTrack(file: string): Promise { return result?.code === 1; } -/** teamai's block and what surrounds it; null without both markers, so a damaged block never takes the member's lines with it. */ +/** + * teamai's block and what surrounds it; null without both markers, so a damaged + * block never takes the member's lines with it. The last start marker opens it: + * one that lost its end marker is left behind, not paired with the next block's end. + */ function splitBlock(content: string): { before: string; patterns: string[]; after: string } | null { - const start = content.indexOf(MCP_EXCLUDE_START); + const start = content.lastIndexOf(MCP_EXCLUDE_START); const endAt = start === -1 ? -1 : content.indexOf(MCP_EXCLUDE_END, start); if (endAt === -1) return null; const patterns = content.slice(start + MCP_EXCLUDE_START.length, endAt) @@ -97,24 +103,27 @@ export async function excludeFromGit(file: string): Promise { } } -/** teamai's block in the `.git/info/exclude` of the repository at `root`, with the file holding it. */ -async function readBlock(root: string): Promise<{ excludeFile: string; block: NonNullable> } | null> { - const location = await gitExcludeFile(root); - if (!location) return null; - const content = await readFileSafe(location.excludeFile); - const block = content === null ? null : splitBlock(content); - return block ? { excludeFile: location.excludeFile, block } : null; -} - -/** Whether the `.git/info/exclude` of the repository at `root` holds teamai's block. */ -export async function hasMcpGitExclude(root: string): Promise { - return await readBlock(root) !== null; +/** + * The `.git/info/exclude` files holding teamai's block, one per repository + * among those `dirs` are in: a config inside a nested repository or submodule + * is excluded from that repository, not from the project root's. + */ +export async function findMcpGitExcludes(dirs: Iterable): Promise { + const found = new Set(); + for (const dir of new Set(dirs)) { + const location = await gitExcludeFile(dir); + if (!location || found.has(location.excludeFile)) continue; + const content = await readFileSafe(location.excludeFile); + if (content !== null && splitBlock(content)) found.add(location.excludeFile); + } + return [...found]; } -/** Remove teamai's block from the `.git/info/exclude` of the repository at `root`. */ -export async function removeMcpGitExclude(root: string): Promise { - const found = await readBlock(root); - if (!found) return false; - await fse.writeFile(found.excludeFile, found.block.before + found.block.after); +/** Remove teamai's block from `excludeFile`, one `findMcpGitExcludes` returned. */ +export async function removeMcpGitExclude(excludeFile: string): Promise { + const content = await readFileSafe(excludeFile); + const block = content === null ? null : splitBlock(content); + if (!block) return false; + await fse.writeFile(excludeFile, block.before + block.after); return true; } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 40604dd5f..8ebdb56a2 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -92,6 +92,8 @@ export interface McpReconcileResult { * parse, a name twice): nothing was changed, and the reason was reported. */ unresolved?: true; + /** Config files still holding servers teamai manages, left in place because they do not parse. */ + leftInPlace?: string[]; } // ─── Manifest ──────────────────────────────────────────────── @@ -560,6 +562,7 @@ export async function reconcileMcpForConfig( if (teamDefs.length === 0 && nothingOwned) return { changes, wrote }; const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); + const leftInPlace: string[] = []; for (const target of targets) { // Same enabledAgents / disabledAgents gate as the other resource syncs. The @@ -578,7 +581,9 @@ export async function reconcileMcpForConfig( if (target.format === 'codex') { wrote = await applyCodex(target, desired, ownedNames, nextRecords, changes, options) || wrote; } else { - wrote = await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options) || wrote; + const applied = await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options); + if (applied === null && ownedNames.size > 0) leftInPlace.push(target.file); + wrote = applied === true || wrote; } if (nextRecords.length > 0) manifest[manifestKey] = nextRecords; @@ -593,11 +598,12 @@ export async function reconcileMcpForConfig( if (!options.dryRun && wrote) { await writeJsonAtomic(manifestPath, manifest); } - return { changes, wrote }; + return { changes, wrote, ...(leftInPlace.length > 0 ? { leftInPlace } : {}) }; } // ─── Appliers ──────────────────────────────────────────────── +/** Whether the file was written; null when it does not parse and was left as it is. */ async function applyJson( target: McpTarget, desired: Map, @@ -606,13 +612,13 @@ async function applyJson( nextRecords: ManagedMcpRecord[], changes: McpChange[], options: McpReconcileOptions, -): Promise { +): Promise { const serverKey = MCP_SERVER_KEY[target.format as Exclude]; const allowBare = target.format === 'copilot' && target.projectScope; const doc = await readJsonDoc(target.file, serverKey, allowBare); if (!doc) { log.warn(`Could not parse ${target.file} — skipping MCP injection for ${target.tool}`); - return false; + return null; } const ownedHash = new Map(owned.map((r) => [r.name, r.hash])); diff --git a/src/uninstall.ts b/src/uninstall.ts index 5412cc6e4..a29f4e0e8 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -113,8 +113,8 @@ interface RemovalPlan { shellProfiles: string[]; /** Docs directory (null if doesn't exist). */ docsDir: string | null; - /** Whether the project's .git/info/exclude holds teamai's MCP config block (#882). */ - gitExcludeBlock: boolean; + /** The .git/info/exclude files holding teamai's MCP config block (#882). */ + gitExcludeFiles: string[]; /** The .teamai home directory path. */ teamaiHome: string; /** Whether teamaiHome exists on disk. */ @@ -491,6 +491,23 @@ async function discoverToolResources( return res; } +/** + * `localConfig` and, in project scope, one config per other linked worktree: + * each worktree has its own MCP configs and managed-mcp manifest. + */ +async function projectWorktreeConfigs(localConfig: LocalConfig): Promise { + const configs: LocalConfig[] = [localConfig]; + if (localConfig.scope === 'project' && localConfig.projectRoot) { + const { listWorktrees } = await import('./utils/git.js'); + const { resolveProjectDataHome } = await import('./config.js'); + for (const wt of await listWorktrees(localConfig.projectRoot)) { + if (wt === localConfig.projectRoot) continue; + configs.push({ ...localConfig, projectRoot: wt, dataHome: await resolveProjectDataHome(wt) }); + } + } + return configs; +} + async function buildRemovalPlan( localConfig: LocalConfig, teamConfig: TeamaiConfig, @@ -621,7 +638,7 @@ async function buildRemovalPlan( mcpServers: [], shellProfiles: [], docsDir: null, - gitExcludeBlock: false, + gitExcludeFiles: [], teamaiHome, teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], @@ -718,11 +735,18 @@ async function buildRemovalPlan( plan.docsDir = docsDir; } - // (g) teamai's block in the project's .git/info/exclude (#882). It counts on - // its own: a clone whose other resources are gone still gets it removed. - if (localConfig.scope === 'project' && localConfig.projectRoot) { - const { hasMcpGitExclude } = await import('./mcp-git-exclude.js'); - plan.gitExcludeBlock = await hasMcpGitExclude(localConfig.projectRoot); + // (g) teamai's block in .git/info/exclude (#882): the project's own, and + // that of any nested repository an MCP config sits in. It counts on its + // own: a clone whose other resources are gone still gets it removed. + if (localConfig.scope === 'project') { + const { resolveMcpTargets } = await import('./mcp-reconcile.js'); + const { findMcpGitExcludes } = await import('./mcp-git-exclude.js'); + const dirs: string[] = []; + for (const cfg of await projectWorktreeConfigs(localConfig)) { + if (cfg.projectRoot) dirs.push(cfg.projectRoot); + for (const target of await resolveMcpTargets(teamConfig, cfg)) dirs.push(path.dirname(target.file)); + } + plan.gitExcludeFiles = await findMcpGitExcludes(dirs); } } @@ -746,7 +770,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.mcpServers.length === 0 && plan.shellProfiles.length === 0 && plan.docsDir === null && - !plan.gitExcludeBlock && + plan.gitExcludeFiles.length === 0 && !plan.teamaiHomeExists ); } @@ -861,9 +885,9 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } - if (plan.gitExcludeBlock) { - console.log(' Git exclude entries for MCP configs:'); - console.log(' teamai\'s block in .git/info/exclude'); + if (plan.gitExcludeFiles.length > 0) { + console.log(' Git exclude entries for MCP configs (teamai\'s block):'); + for (const file of plan.gitExcludeFiles) console.log(` ${file}`); console.log(''); } @@ -1230,28 +1254,29 @@ export async function uninstall(opts: UninstallOptions): Promise { // linked worktree — otherwise a sibling worktree is left with an injected // server whose ownership record just got deleted (orphaned). User scope // has a single global manifest, so the current config is enough. - const configs: LocalConfig[] = [localConfig]; - if (localConfig.scope === 'project' && localConfig.projectRoot) { - const { listWorktrees } = await import('./utils/git.js'); - const { resolveProjectDataHome } = await import('./config.js'); - const worktrees = await listWorktrees(localConfig.projectRoot); - for (const wt of worktrees) { - if (wt === localConfig.projectRoot) continue; - const dataHome = await resolveProjectDataHome(wt); - configs.push({ ...localConfig, projectRoot: wt, dataHome }); - } - } let removedTotal = 0; - for (const cfg of configs) { - const { changes } = await reconcileMcpForConfig(teamConfig, cfg, { removeAll: true }); - removedTotal += changes.filter((c) => c.action === 'removed').length; + const leftInPlace: string[] = []; + for (const cfg of await projectWorktreeConfigs(localConfig)) { + const result = await reconcileMcpForConfig(teamConfig, cfg, { removeAll: true }); + removedTotal += result.changes.filter((c) => c.action === 'removed').length; + leftInPlace.push(...result.leftInPlace ?? []); } if (removedTotal > 0) log.info(`Removed ${removedTotal} teamai-managed MCP server(s)`); - // Every worktree shares one info/exclude, so it goes once they are all clean. - if (plan.gitExcludeBlock && localConfig.projectRoot) { - const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); - if (await removeMcpGitExclude(localConfig.projectRoot)) { - log.info('Removed teamai\'s MCP config entries from .git/info/exclude'); + // Worktrees share one info/exclude, so it goes once they are all clean. A + // config still holding teamai's servers keeps its repository's block: + // without it, `git add -A` would commit the values they resolved. + if (plan.gitExcludeFiles.length > 0) { + const { findMcpGitExcludes, removeMcpGitExclude } = await import('./mcp-git-exclude.js'); + const kept = new Set(await findMcpGitExcludes(leftInPlace.map((file) => path.dirname(file)))); + for (const excludeFile of plan.gitExcludeFiles) { + if (kept.has(excludeFile)) { + log.warn( + `Kept teamai's block in ${excludeFile}: ${leftInPlace.join(', ')} could not be parsed, so the teamai MCP servers there ` + + 'were not removed and may hold resolved values in plaintext. Remove those servers yourself, then delete the block.', + ); + } else if (await removeMcpGitExclude(excludeFile)) { + log.info(`Removed teamai's MCP config entries from ${excludeFile}`); + } } } } catch (e) { From dba5374fdb6474864d1793cf8f2cd832b1fd7d15 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 28 Sep 2026 18:51:44 +0200 Subject: [PATCH 04/85] fix(uninstall): remove the exclude block only once its files are proven clean (#882) A missing or unreadable managed-mcp.json made the MCP cleanup return early without reporting anything, so uninstall removed the block while .mcp.json still held the resolved token. Uninstall now inspects every path the block protects after the cleanup. The block goes only when each one is missing, or parses and holds none of the team's servers that need a resolved ${VAR}. Anything it cannot check keeps the block, with a warning naming the file. This replaces the leftInPlace report from the reconcile, which the check subsumes. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/uninstall.test.ts | 61 +++++++++++++++++++++++++++ src/mcp-git-exclude.ts | 41 ++++++++++++------ src/mcp-reconcile.ts | 14 ++---- src/uninstall.ts | 75 ++++++++++++++++++++++++--------- 6 files changed, 148 insertions(+), 47 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7e0f6de34..5b5d0a137 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. When teamai writes such a file and git would track it, it lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it); the committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block — unless a config it could not parse still holds teamai's servers, in which case it keeps the block and warns. `teamai doctor` reports such a file git would still commit — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. When teamai writes such a file and git would track it, it lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it); the committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file the block lists is gone or holds none of the team's servers with a resolved value; a file it cannot check (for example one that does not parse) keeps the block, with a warning. `teamai doctor` reports such a file git would still commit — for example one already tracked: `git rm --cached` it and rotate the token. 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 dd56a6560..0e0a7fc9f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。teamai 写入这类文件且 git 会跟踪它时,会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件);不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块——若某个配置无法解析、仍含 teamai 管理的 server,则保留该块并给出警告。`teamai doctor` 会报告 git 仍会提交的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。teamai 写入这类文件且 git 会跟踪它时,会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件);不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件都已不存在,或不再含带解析值的团队 server;无法检查的文件(例如无法解析)会保留该块并给出警告。`teamai doctor` 会报告 git 仍会提交的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index b43c580ef..18428a324 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -799,6 +799,67 @@ describe('uninstall', () => { expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept teamai's block in ${await fse.realpath(excludeFile)}`)); }); + describe('the block protects a config holding a resolved value (#882)', () => { + const block = [ + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/.mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n'); + const jira = { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer t0ken' } }; + + async function setup(): Promise<{ projectRoot: string; excludeFile: string; localConfig: LocalConfig }> { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.outputFile(path.join(repoPath, 'mcp', 'mcp.yaml'), [ + 'servers:', + ' - name: jira', + ' transport: http', + ' url: https://jira.example/mcp', + ' headers:', + ' Authorization: "Bearer ${JIRA_TOKEN}"', + '', + ].join('\n')); + await fse.ensureDir(path.join(projectRoot, '.claude', 'skills')); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + const excludeFile = path.join(projectRoot, '.git', 'info', 'exclude'); + await fse.writeFile(excludeFile, block); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + const teamConfig = makeTeamConfig({ + toolPaths: { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' } }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + return { projectRoot, excludeFile, localConfig }; + } + + it('keeps the block when managed-mcp.json is gone and the token is still in .mcp.json', async () => { + const { projectRoot, excludeFile } = await setup(); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira } }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readJson(path.join(projectRoot, '.mcp.json'))).toEqual({ mcpServers: { jira } }); + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept teamai's block in ${await fse.realpath(excludeFile)}`)); + }); + + it('removes the block once uninstall has taken teamai\'s servers out of .mcp.json', async () => { + const { projectRoot, excludeFile, localConfig } = await setup(); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira, mine: { command: 'mine' } } }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], + }); + + await uninstall({ force: true }); + + expect(await fse.readJson(path.join(projectRoot, '.mcp.json'))).toEqual({ mcpServers: { mine: { command: 'mine' } } }); + expect(await fse.readFile(excludeFile, 'utf8')).toBe(''); + }); + }); + it('project-scope uninstall removes the block from a nested repository holding an MCP config (#882)', async () => { const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(tmpDir, 'team-repo'); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 82e5fb249..337649d3e 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -35,16 +35,19 @@ export function carriesResolvedValue( && !supportsEnvExpansion(target.format, target.projectScope, def)); } -/** The `info/exclude` git reads for `dir`'s checkout (worktrees and submodules included), and `dir`'s path from its root. */ -async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; prefix: string } | null> { - const result = await execCommand('git', ['rev-parse', '--show-prefix', '--git-path', 'info/exclude'], { cwd: dir, timeoutMs: 10_000 }) +/** + * The `info/exclude` git reads for `dir`'s checkout (worktrees and submodules + * included), the checkout's root, and `dir`'s path from it. + */ +async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; root: string; prefix: string } | null> { + const result = await execCommand('git', ['rev-parse', '--show-toplevel', '--show-prefix', '--git-path', 'info/exclude'], { cwd: dir, timeoutMs: 10_000 }) .catch(() => null); if (!result || result.code !== 0) return null; - const [prefix = '', gitPath = ''] = result.stdout.split(/\r?\n/); - if (!gitPath) return null; + const [root = '', prefix = '', gitPath = ''] = result.stdout.split(/\r?\n/); + if (!root || !gitPath) return null; // Real path, so one repository reached through a symlink (macOS /var) is one file. const base = await fse.realpath(dir).catch(() => dir); - return { excludeFile: path.resolve(base, gitPath), prefix }; + return { excludeFile: path.resolve(base, gitPath), root, prefix }; } /** @@ -105,18 +108,28 @@ export async function excludeFromGit(file: string): Promise { /** * The `.git/info/exclude` files holding teamai's block, one per repository - * among those `dirs` are in: a config inside a nested repository or submodule - * is excluded from that repository, not from the project root's. + * among those `dirs` are in (a config inside a nested repository or submodule + * is excluded from that repository, not from the project root's), each with + * the absolute paths its block protects in the checkouts `dirs` reach. */ -export async function findMcpGitExcludes(dirs: Iterable): Promise { - const found = new Set(); +export async function findMcpGitExcludes(dirs: Iterable): Promise> { + const roots = new Map>(); for (const dir of new Set(dirs)) { const location = await gitExcludeFile(dir); - if (!location || found.has(location.excludeFile)) continue; - const content = await readFileSafe(location.excludeFile); - if (content !== null && splitBlock(content)) found.add(location.excludeFile); + if (!location) continue; + const seen = roots.get(location.excludeFile) ?? new Set(); + roots.set(location.excludeFile, seen.add(location.root)); + } + const found = new Map(); + for (const [excludeFile, checkouts] of roots) { + const content = await readFileSafe(excludeFile); + const block = content === null ? null : splitBlock(content); + if (!block) continue; + // Each pattern is `/`, glob characters escaped (see excludeFromGit). + const rels = block.patterns.map((p) => p.replace(/^\//, '').replace(/\\(.)/g, '$1')); + found.set(excludeFile, [...checkouts].flatMap((root) => rels.map((rel) => path.join(root, rel)))); } - return [...found]; + return found; } /** Remove teamai's block from `excludeFile`, one `findMcpGitExcludes` returned. */ diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 8ebdb56a2..40604dd5f 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -92,8 +92,6 @@ export interface McpReconcileResult { * parse, a name twice): nothing was changed, and the reason was reported. */ unresolved?: true; - /** Config files still holding servers teamai manages, left in place because they do not parse. */ - leftInPlace?: string[]; } // ─── Manifest ──────────────────────────────────────────────── @@ -562,7 +560,6 @@ export async function reconcileMcpForConfig( if (teamDefs.length === 0 && nothingOwned) return { changes, wrote }; const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); - const leftInPlace: string[] = []; for (const target of targets) { // Same enabledAgents / disabledAgents gate as the other resource syncs. The @@ -581,9 +578,7 @@ export async function reconcileMcpForConfig( if (target.format === 'codex') { wrote = await applyCodex(target, desired, ownedNames, nextRecords, changes, options) || wrote; } else { - const applied = await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options); - if (applied === null && ownedNames.size > 0) leftInPlace.push(target.file); - wrote = applied === true || wrote; + wrote = await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options) || wrote; } if (nextRecords.length > 0) manifest[manifestKey] = nextRecords; @@ -598,12 +593,11 @@ export async function reconcileMcpForConfig( if (!options.dryRun && wrote) { await writeJsonAtomic(manifestPath, manifest); } - return { changes, wrote, ...(leftInPlace.length > 0 ? { leftInPlace } : {}) }; + return { changes, wrote }; } // ─── Appliers ──────────────────────────────────────────────── -/** Whether the file was written; null when it does not parse and was left as it is. */ async function applyJson( target: McpTarget, desired: Map, @@ -612,13 +606,13 @@ async function applyJson( nextRecords: ManagedMcpRecord[], changes: McpChange[], options: McpReconcileOptions, -): Promise { +): Promise { const serverKey = MCP_SERVER_KEY[target.format as Exclude]; const allowBare = target.format === 'copilot' && target.projectScope; const doc = await readJsonDoc(target.file, serverKey, allowBare); if (!doc) { log.warn(`Could not parse ${target.file} — skipping MCP injection for ${target.tool}`); - return null; + return false; } const ownedHash = new Map(owned.map((r) => [r.name, r.hash])); diff --git a/src/uninstall.ts b/src/uninstall.ts index a29f4e0e8..62019cd2a 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import fs from 'node:fs/promises'; import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope } from './config.js'; import { reconcileHooks, hasTeamaiHooks } from './hooks.js'; import { @@ -33,6 +34,7 @@ import { type Scope, type ManagedMcpManifest, } from './types.js'; +import type { McpTarget } from './mcp-reconcile.js'; import { BUILTIN_RULE_NAMES } from './builtin-rules.js'; import { ruleStemFromFilename } from './resources/rule-format.js'; import { agentStemFromFilename } from './resources/agent-format.js'; @@ -113,8 +115,8 @@ interface RemovalPlan { shellProfiles: string[]; /** Docs directory (null if doesn't exist). */ docsDir: string | null; - /** The .git/info/exclude files holding teamai's MCP config block (#882). */ - gitExcludeFiles: string[]; + /** The .git/info/exclude files holding teamai's MCP config block (#882), each with the paths it protects. */ + gitExcludes: Map; /** The .teamai home directory path. */ teamaiHome: string; /** Whether teamaiHome exists on disk. */ @@ -508,6 +510,37 @@ async function projectWorktreeConfigs(localConfig: LocalConfig): Promise { + const { resolveMcpTargets, installedMcpEntries } = await import('./mcp-reconcile.js'); + const { carriesResolvedValue } = await import('./mcp-git-exclude.js'); + const { mcpEntryReader, teamMcpToDef } = await import('./resources/mcp.js'); + const { resolveEntriesFor } = await import('./namespaced-entries.js'); + const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); + const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); + // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). + const targets = new Map(); + for (const cfg of await projectWorktreeConfigs(localConfig)) { + for (const target of await resolveMcpTargets(teamConfig, cfg)) { + const dir = await fs.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); + targets.set(path.join(dir, path.basename(target.file)), target); + } + } + const held: string[] = []; + for (const file of files) { + if (!await pathExists(file)) continue; + const target = targets.get(file); + const installed = target ? await installedMcpEntries(target) : null; + if (!target || !installed || !teamDefs || carriesResolvedValue(target, teamDefs, installed.keys())) held.push(file); + } + return held; +} + async function buildRemovalPlan( localConfig: LocalConfig, teamConfig: TeamaiConfig, @@ -638,7 +671,7 @@ async function buildRemovalPlan( mcpServers: [], shellProfiles: [], docsDir: null, - gitExcludeFiles: [], + gitExcludes: new Map(), teamaiHome, teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], @@ -746,7 +779,7 @@ async function buildRemovalPlan( if (cfg.projectRoot) dirs.push(cfg.projectRoot); for (const target of await resolveMcpTargets(teamConfig, cfg)) dirs.push(path.dirname(target.file)); } - plan.gitExcludeFiles = await findMcpGitExcludes(dirs); + plan.gitExcludes = await findMcpGitExcludes(dirs); } } @@ -770,7 +803,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.mcpServers.length === 0 && plan.shellProfiles.length === 0 && plan.docsDir === null && - plan.gitExcludeFiles.length === 0 && + plan.gitExcludes.size === 0 && !plan.teamaiHomeExists ); } @@ -885,9 +918,9 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } - if (plan.gitExcludeFiles.length > 0) { + if (plan.gitExcludes.size > 0) { console.log(' Git exclude entries for MCP configs (teamai\'s block):'); - for (const file of plan.gitExcludeFiles) console.log(` ${file}`); + for (const file of plan.gitExcludes.keys()) console.log(` ${file}`); console.log(''); } @@ -1255,24 +1288,24 @@ export async function uninstall(opts: UninstallOptions): Promise { // server whose ownership record just got deleted (orphaned). User scope // has a single global manifest, so the current config is enough. let removedTotal = 0; - const leftInPlace: string[] = []; for (const cfg of await projectWorktreeConfigs(localConfig)) { - const result = await reconcileMcpForConfig(teamConfig, cfg, { removeAll: true }); - removedTotal += result.changes.filter((c) => c.action === 'removed').length; - leftInPlace.push(...result.leftInPlace ?? []); + const { changes } = await reconcileMcpForConfig(teamConfig, cfg, { removeAll: true }); + removedTotal += changes.filter((c) => c.action === 'removed').length; } if (removedTotal > 0) log.info(`Removed ${removedTotal} teamai-managed MCP server(s)`); - // Worktrees share one info/exclude, so it goes once they are all clean. A - // config still holding teamai's servers keeps its repository's block: - // without it, `git add -A` would commit the values they resolved. - if (plan.gitExcludeFiles.length > 0) { - const { findMcpGitExcludes, removeMcpGitExclude } = await import('./mcp-git-exclude.js'); - const kept = new Set(await findMcpGitExcludes(leftInPlace.map((file) => path.dirname(file)))); - for (const excludeFile of plan.gitExcludeFiles) { - if (kept.has(excludeFile)) { + // Worktrees share one info/exclude, so it goes once they are all clean, + // judged by what the files hold, not by what the cleanup reported: a + // lost manifest cleans nothing and reports nothing. Without the block, + // `git add -A` would commit a value teamai resolved. + if (plan.gitExcludes.size > 0) { + const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); + const held = new Set(await mcpConfigsNotProvenClean(teamConfig, localConfig, [...plan.gitExcludes.values()].flat())); + for (const [excludeFile, protects] of plan.gitExcludes) { + const still = protects.filter((file) => held.has(file)); + if (still.length > 0) { log.warn( - `Kept teamai's block in ${excludeFile}: ${leftInPlace.join(', ')} could not be parsed, so the teamai MCP servers there ` - + 'were not removed and may hold resolved values in plaintext. Remove those servers yourself, then delete the block.', + `Kept teamai's block in ${excludeFile}: ${still.join(', ')} may still hold MCP values teamai resolved to plaintext, ` + + 'or could not be read. Remove the team\'s MCP servers from it yourself, then delete the block.', ); } else if (await removeMcpGitExclude(excludeFile)) { log.info(`Removed teamai's MCP config entries from ${excludeFile}`); From 336a3b08104e13ef05a6e33962a670ea5da06c70 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 28 Sep 2026 19:20:48 +0200 Subject: [PATCH 05/85] fix(mcp): protect every project MCP config holding a resolved value (#882) Pull, doctor and uninstall each skipped a case they had not inspected and treated it as safe. Now: - pull lists a config in .git/info/exclude whether or not it delivered to it this run: a disabled or undetected tool's file, a team with automatic delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed write to another tool's config, and a lost ownership manifest (the resolved value found in the file) - doctor checks the same files, including one that does not parse, and counts a git error as a failure - git check-ignore failing inside a repository is no longer read as "not tracked": the path is excluded anyway, or teamai warns with git's error - uninstall also keeps the block while a file contains the value (8+ characters, not a path or the login name) of a variable still set in the environment, which finds a server since dropped from mcp.yaml --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/doctor-mcp-delivery.test.ts | 30 ++++++++++ src/__tests__/mcp-git-exclude.test.ts | 47 ++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 60 ++++++++++++++++++++ src/__tests__/uninstall.test.ts | 25 +++++++-- src/doctor-delivery.ts | 27 ++++++--- src/mcp-git-exclude.ts | 67 +++++++++++++++++++---- src/mcp-reconcile.ts | 59 +++++++++++++++++--- src/uninstall.ts | 23 +++++--- 10 files changed, 300 insertions(+), 42 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5b5d0a137..b99d5cf32 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. When teamai writes such a file and git would track it, it lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it); the committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file the block lists is gone or holds none of the team's servers with a resolved value; a file it cannot check (for example one that does not parse) keeps the block, with a warning. `teamai doctor` reports such a file git would still commit — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write, such as one written earlier for a tool since disabled; a path git cannot answer for is listed all the same, or teamai warns. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file the block lists is gone, or holds neither a team server with a resolved value nor the value (8+ characters) of a variable still set in the environment; a file it cannot check (for example one that does not parse) keeps the block, with a warning. `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 0e0a7fc9f..e474a8a4b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。teamai 写入这类文件且 git 会跟踪它时,会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件);不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件都已不存在,或不再含带解析值的团队 server;无法检查的文件(例如无法解析)会保留该块并给出警告。`teamai doctor` 会报告 git 仍会提交的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用,例如之前为某个现已禁用的工具写入的文件;git 无法判断的路径也会照样写入,否则 teamai 会给出警告。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件都已不存在,或既不含带解析值的团队 server,也不含仍在环境中设置的变量的值(8 个字符以上);无法检查的文件(例如无法解析)会保留该块并给出警告。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 5f4c0893d..9d0353497 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -273,6 +273,36 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await check.check()).toBe(true); }); + it.each([ + ['its tool is disabled', async () => { localConfig.disabledAgents = ['claude', 'tclaude']; }], + ['its tool is no longer detected', async () => { await fse.remove(path.join(projectRoot, '.claude')); }], + ['it does not parse', async () => { + await fse.writeFile(path.join(projectRoot, '.mcp.json'), '{ "mcpServers": { "jira": { "headers": { "Authorization": "Bearer t0ken" } } },\n'); + }], + ['git cannot say whether it would commit it', async () => { + await fse.writeFile(path.join(projectRoot, '.git', 'config'), '[core\nbroken\n'); + }], + ])('still fails while git would track the file when %s', async (_label, arrange) => { + await arrange(); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); + }); + + it('still fails when the manifest is gone but the resolved value is in the file', async () => { + vi.stubEnv('JIRA_TOKEN', 'long-t0ken-value-7c1'); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer long-t0ken-value-7c1' } } }, + }); + await fse.remove(managedMcpManifestPath(getDataHome(localConfig), projectRoot)); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + }); + it('emits no check when the server of that name is the member\'s own, not teamai\'s', async () => { await fse.remove(managedMcpManifestPath(getDataHome(localConfig), projectRoot)); diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index d442041f1..ed28b699b 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -8,7 +8,21 @@ vi.mock('../utils/logger.js', () => ({ log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), dim: vi.fn() }, })); +// Git's own failure modes (unsafe repository, bad config) are hard to stage for one subcommand alone. +const failCheckIgnore = vi.hoisted(() => ({ on: false })); +vi.mock('../utils/exec.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execCommand: (cmd: string, args: string[], opts?: Parameters[2]) => + failCheckIgnore.on && args[0] === 'check-ignore' + ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository' }) + : actual.execCommand(cmd, args, opts), + }; +}); + import { MCP_EXCLUDE_START, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; +import { log } from '../utils/logger.js'; describe('teamai block in .git/info/exclude (#882)', () => { let repo: string; @@ -21,9 +35,42 @@ describe('teamai block in .git/info/exclude (#882)', () => { }); afterEach(async () => { + failCheckIgnore.on = false; + vi.mocked(log.warn).mockClear(); await fse.remove(repo); }); + describe('when git cannot say whether it would commit the file', () => { + it('still excludes it while the exclude file is reachable', async () => { + await fse.writeJson(path.join(repo, '.mcp.json'), {}); + failCheckIgnore.on = true; + + await excludeFromGit(path.join(repo, '.mcp.json')); + + expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/\.mcp\.json$/m); + }); + + it('warns with the file and git\'s error when it is not', async () => { + await fse.writeJson(path.join(repo, '.mcp.json'), {}); + await fse.writeFile(path.join(repo, '.git', 'config'), '[core\nbroken\n'); + + await excludeFromGit(path.join(repo, '.mcp.json')); + + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(repo, '.mcp.json'))); + expect(log.warn).toHaveBeenCalledWith(expect.stringMatching(/config/)); + }); + }); + + it('stays quiet outside any repository', async () => { + const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); + await fse.writeJson(path.join(outside, '.mcp.json'), {}); + + await excludeFromGit(path.join(outside, '.mcp.json')); + + expect(log.warn).not.toHaveBeenCalled(); + await fse.remove(outside); + }); + it('never takes the member\'s lines when a start marker has lost its end marker', async () => { await fse.writeFile(excludeFile, `${MCP_EXCLUDE_START}\n/old.json\nscratch/\n`); await fse.writeJson(path.join(repo, '.mcp.json'), {}); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 2397f2234..549a8b669 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -934,6 +934,66 @@ servers: expect(git(worktree, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/ (\.mcp\.json|\.cursor\/)/); }); + describe('a config an earlier pull wrote is protected even when this pull delivers nothing to it', () => { + beforeEach(async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, projectConfig); + // As if written before this release: the token is on disk, nothing excludes it. + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + }); + + it('when its tool is disabled', async () => { + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + + it('when the team turned automatic MCP delivery off', async () => { + const manual = { ...teamConfig, sharing: { ...teamConfig.sharing, mcp: { autoApply: false } } } as TeamaiConfig; + + await reconcileMcpForConfig(manual, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + + it('when its tool is no longer detected', async () => { + await fse.remove(path.join(projectRoot, '.claude')); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it.skipIf(process.getuid?.() === 0)('when writing another tool\'s config fails', async () => { + await writeMcpYaml(`${withSecret} - name: added-later\n transport: http\n url: https://example.com/later\n`); + await fse.chmod(path.join(projectRoot, '.cursor'), 0o555); + + await expect(reconcileMcpForConfig(teamConfig, projectConfig)).rejects.toThrow(); + await fse.chmod(path.join(projectRoot, '.cursor'), 0o755); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + + it('when its ownership manifest is gone', async () => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(managedMcpManifestPath(getDataHome(projectConfig), projectRoot)); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('when the team\'s mcp.yaml does not parse', async () => { + await writeMcpYaml('servers: [unclosed\n'); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + }); + it('leaves .git/info/exclude alone on a dry run', async () => { await writeMcpYaml(withSecret); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 18428a324..a5301f2b7 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -808,7 +808,7 @@ describe('uninstall', () => { ].join('\n'); const jira = { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer t0ken' } }; - async function setup(): Promise<{ projectRoot: string; excludeFile: string; localConfig: LocalConfig }> { + async function setup(): Promise<{ homeDir: string; repoPath: string; projectRoot: string; excludeFile: string; localConfig: LocalConfig }> { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); vi.stubEnv('HOME', homeDir); @@ -831,7 +831,7 @@ describe('uninstall', () => { toolPaths: { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' } }, }); mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); - return { projectRoot, excludeFile, localConfig }; + return { homeDir, repoPath, projectRoot, excludeFile, localConfig }; } it('keeps the block when managed-mcp.json is gone and the token is still in .mcp.json', async () => { @@ -846,16 +846,31 @@ describe('uninstall', () => { expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept teamai's block in ${await fse.realpath(excludeFile)}`)); }); + it('keeps the block when a server dropped from mcp.yaml left its token behind with no manifest', async () => { + const { repoPath, projectRoot, excludeFile } = await setup(); + await fse.outputFile(path.join(repoPath, 'mcp', 'mcp.yaml'), 'servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + vi.stubEnv('JIRA_TOKEN', 't0ken-still-set-9f2'); + const stale = { ...jira, headers: { Authorization: 'Bearer t0ken-still-set-9f2' } }; + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira: stale } }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + }); + it('removes the block once uninstall has taken teamai\'s servers out of .mcp.json', async () => { - const { projectRoot, excludeFile, localConfig } = await setup(); - await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira, mine: { command: 'mine' } } }); + const { homeDir, projectRoot, excludeFile, localConfig } = await setup(); + // A path and the login name are in the environment and in ordinary configs: neither holds the block. + vi.stubEnv('USER', 'longusername1'); + const mine = { command: path.join(homeDir, 'bin', 'mine'), env: { OWNER: 'longusername1' } }; + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira, mine } }); await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], }); await uninstall({ force: true }); - expect(await fse.readJson(path.join(projectRoot, '.mcp.json'))).toEqual({ mcpServers: { mine: { command: 'mine' } } }); + expect(await fse.readJson(path.join(projectRoot, '.mcp.json'))).toEqual({ mcpServers: { mine } }); expect(await fse.readFile(excludeFile, 'utf8')).toBe(''); }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 1e94bd21b..539db54d8 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -510,8 +510,8 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise teamMcpToDef(entry.entry)); let manifest: ManagedMcpManifest | undefined; + let vars: Record | undefined; const tracked: string[] = []; let holding = 0; - for (const target of await resolveMcpTargets(teamConfig, localConfig)) { - if (mcpTargetExcluded(localConfig, target)) continue; + // Every tool's file, delivery on or off: a disabled or undetected tool's file keeps what a pull wrote. + for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { + const raw = await readFileSafe(target.file); + if (raw === null) continue; const installed = await installedMcpEntries(target); - if (!installed || !carriesResolvedValue(target, teamDefs, installed.keys())) continue; // Only servers teamai owns: a member's own server under a team name holds no value teamai resolved. + // A file that does not parse is judged by what the manifest says teamai put there, and the value + // itself is found without the manifest, which can be lost. manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - if (!carriesResolvedValue(target, teamDefs, owned.filter((name) => installed.has(name)))) continue; + if (!carriesResolvedValue(target, teamDefs, installed ? owned.filter((name) => installed.has(name)) : owned)) { + vars ??= await buildVarTable(localConfig); + if (!holdsResolvedValue(target, teamDefs, vars, raw)) continue; + } holding += 1; - if (await gitWouldTrack(target.file)) tracked.push(target.file); + const tracking = await gitTracking(target.file); + if (tracking.kind === 'would-commit') tracked.push(target.file); + else if (tracking.kind === 'unknown') tracked.push(`${target.file} (git failed: ${tracking.error})`); } if (holding === 0) return []; @@ -540,8 +549,8 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise tracked.length === 0, - fix: `${tracked.join(', ')} hold MCP variables resolved to plaintext, and git would commit them. ` - + 'Run `teamai pull` to list them in .git/info/exclude. If git already tracks one, run ' + fix: `${tracked.join(', ')} hold MCP variables resolved to plaintext, and git would commit them or cannot say. ` + + 'Fix any git error shown, then run `teamai pull` to list them in .git/info/exclude. If git already tracks one, run ' + '`git rm --cached ` and rotate the values it held.', }]; } diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 337649d3e..d24231fb1 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -35,6 +35,26 @@ export function carriesResolvedValue( && !supportsEnvExpansion(target.format, target.projectScope, def)); } +/** + * Whether `raw`, a project file's text, holds a value teamai resolves into + * `target`: the value in `vars` (8+ characters, shorter ones turn up anywhere) + * of a variable one of `teamDefs` references and the tool does not expand + * itself. Needs no ownership manifest. + */ +export function holdsResolvedValue( + target: McpTarget, + teamDefs: McpServerDef[], + vars: Record, + raw: string, +): boolean { + if (!target.projectScope) return false; + return teamDefs.some((def) => !supportsEnvExpansion(target.format, target.projectScope, def) + && referencedVars(def).some((name) => { + const value = vars[name]; + return value !== undefined && value.length >= 8 && raw.includes(value); + })); +} + /** * The `info/exclude` git reads for `dir`'s checkout (worktrees and submodules * included), the checkout's root, and `dir`'s path from it. @@ -51,14 +71,27 @@ async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; root: } /** - * Whether git would put `file` in a commit: in a repository, and tracked or - * untracked without an ignore rule. Read-only. + * Whether git would put a file in a commit. `unknown` is a repository git could + * not answer for (unsafe ownership, a bad config): never read it as safe. */ -export async function gitWouldTrack(file: string): Promise { - const result = await execCommand('git', ['check-ignore', '-q', '--', path.basename(file)], { cwd: path.dirname(file), timeoutMs: 10_000 }) - .catch(() => null); - // 0: ignored. 1: not ignored (or tracked). 128: not a repository, or git failed. - return result?.code === 1; +export type GitTracking = + | { kind: 'ignored' } + | { kind: 'would-commit' } + | { kind: 'outside-repo' } + | { kind: 'unknown'; error: string }; + +/** Whether git would put `file` in a commit: tracked, or untracked without an ignore rule. Read-only. */ +export async function gitTracking(file: string): Promise { + const dir = path.dirname(file); + const result = await execCommand('git', ['check-ignore', '-q', '--', path.basename(file)], { cwd: dir, timeoutMs: 10_000 }) + .catch((e: unknown) => ({ code: -1, stdout: '', stderr: e instanceof Error ? e.message : String(e) })); + if (result.code === 0) return { kind: 'ignored' }; + if (result.code === 1) return { kind: 'would-commit' }; + // Anything else is no repository at all, or git failing inside one. + for (let d = path.resolve(dir); ; d = path.dirname(d)) { + if (await pathExists(path.join(d, '.git'))) return { kind: 'unknown', error: result.stderr.trim() || `git exited with ${result.code}` }; + if (path.dirname(d) === d) return { kind: 'outside-repo' }; + } } /** @@ -77,14 +110,24 @@ function splitBlock(content: string): { before: string; patterns: string[]; afte } /** - * Add `file` to its repository's `.git/info/exclude` when git would otherwise - * track it. Idempotent; a path already ignored, or outside any repository, - * adds nothing. A failure warns rather than failing the sync that wrote the file. + * Add `file` to its repository's `.git/info/exclude` unless git ignores it + * already. Idempotent; a path already ignored, or outside any repository, adds + * nothing, and one git cannot answer for is added all the same. A failure warns + * rather than failing the sync that wrote the file. */ export async function excludeFromGit(file: string): Promise { - if (!await pathExists(file) || !await gitWouldTrack(file)) return; + if (!await pathExists(file)) return; + const tracking = await gitTracking(file); + if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return; const location = await gitExcludeFile(path.dirname(file)); - if (!location) return; + if (!location) { + const reason = tracking.kind === 'unknown' ? tracking.error : 'git could not locate .git/info/exclude'; + log.warn( + `${file} holds a resolved MCP variable, and teamai could not keep it out of git: ${reason}. ` + + 'Fix the repository, or add the file to its .git/info/exclude yourself, so git does not commit the value.', + ); + return; + } const { excludeFile } = location; // Anchored at the working tree root, glob characters escaped. const pattern = `/${location.prefix}${path.basename(file)}`.replace(/[\\*?[\]!#]/g, '\\$&'); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 40604dd5f..a34c01f3e 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -44,7 +44,7 @@ import { import { log } from './utils/logger.js'; import { loadProjectMcpManifest } from './utils/mcp-manifest.js'; import { isOnPath, SAFE_BIN_RE, type LookPathOptions } from './utils/lookpath.js'; -import { carriesResolvedValue, excludeFromGit } from './mcp-git-exclude.js'; +import { carriesResolvedValue, excludeFromGit, holdsResolvedValue } from './mcp-git-exclude.js'; // ─── Reconcile engine ──────────────────────────────────────── // @@ -216,6 +216,8 @@ export interface McpTarget { export async function resolveMcpTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, + /** Also the tools not detected here: a file an earlier pull wrote outlives its tool. */ + options: { includeUndetected?: boolean } = {}, ): Promise { const projectScope = localConfig.scope === 'project'; const targets: McpTarget[] = []; @@ -239,7 +241,7 @@ export async function resolveMcpTargets( const probe = paths.skills ?? paths.settings ?? paths.agents; if (!probe) continue; - if (!await isToolInstalledForConfig(tool, probe, localConfig, file)) { + if (!options.includeUndetected && !await isToolInstalledForConfig(tool, probe, localConfig, file)) { log.debug(`Skipping MCP sync for ${tool}: tool not installed`); continue; } @@ -507,6 +509,54 @@ export async function reconcileMcpForConfig( teamConfig: TeamaiConfig, localConfig: LocalConfig, options: McpReconcileOptions = {}, +): Promise { + try { + return await reconcileTargets(teamConfig, localConfig, options); + } finally { + // Also after a failed write: what earlier pulls wrote is on disk either way. + if (!options.removeAll && !options.dryRun) await protectResolvedMcpConfigs(teamConfig, localConfig); + } +} + +/** + * List each project MCP config holding a value teamai resolved in + * `.git/info/exclude` (#882). It covers what is on disk, whether or not this + * run delivered to it: the file of a disabled or undetected tool, or one + * written before the team turned delivery off, still holds what a pull wrote. + */ +async function protectResolvedMcpConfigs(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const { projectRoot } = localConfig; + if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; + try { + await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot); + } catch (e) { + log.warn( + `Could not check this project's MCP configs for resolved values to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` + + 'Run `teamai doctor` to see whether git would commit one.', + ); + } +} + +async function protectProjectMcpConfigs(teamConfig: TeamaiConfig, localConfig: LocalConfig, projectRoot: string): Promise { + const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); + const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); + const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const vars = teamDefs ? await buildVarTable(localConfig) : {}; + for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { + const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); + // Team servers that cannot be read say nothing either way: any teamai entry may hold one. + // The value itself is found without the manifest, which can be lost. + const holds = teamDefs + ? carriesResolvedValue(target, teamDefs, owned) || holdsResolvedValue(target, teamDefs, vars, (await readFileSafe(target.file)) ?? '') + : owned.length > 0; + if (holds) await excludeFromGit(target.file); + } +} + +async function reconcileTargets( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + options: McpReconcileOptions, ): Promise { const changes: McpChange[] = []; let wrote = false; @@ -583,11 +633,6 @@ export async function reconcileMcpForConfig( if (nextRecords.length > 0) manifest[manifestKey] = nextRecords; else delete manifest[manifestKey]; - - // A resolved ${VAR} in a project file is plaintext in the business repo. - if (!removeAll && !options.dryRun && carriesResolvedValue(target, teamDefs, nextRecords.map((r) => r.name))) { - await excludeFromGit(target.file); - } } if (!options.dryRun && wrote) { diff --git a/src/uninstall.ts b/src/uninstall.ts index 62019cd2a..e8bb4f643 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -512,12 +512,14 @@ async function projectWorktreeConfigs(localConfig: LocalConfig): Promise { - const { resolveMcpTargets, installedMcpEntries } = await import('./mcp-reconcile.js'); + const { resolveMcpTargets, installedMcpEntries, buildVarTable } = await import('./mcp-reconcile.js'); const { carriesResolvedValue } = await import('./mcp-git-exclude.js'); const { mcpEntryReader, teamMcpToDef } = await import('./resources/mcp.js'); const { resolveEntriesFor } = await import('./namespaced-entries.js'); @@ -526,17 +528,24 @@ async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: L // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). const targets = new Map(); for (const cfg of await projectWorktreeConfigs(localConfig)) { - for (const target of await resolveMcpTargets(teamConfig, cfg)) { + for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fs.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); targets.set(path.join(dir, path.basename(target.file)), target); } } + // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. + const identity = new Set(['USER', 'LOGNAME', 'USERNAME']); + const values = Object.entries(await buildVarTable(localConfig)) + .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)) + .map(([, value]) => value); const held: string[] = []; for (const file of files) { if (!await pathExists(file)) continue; const target = targets.get(file); const installed = target ? await installedMcpEntries(target) : null; - if (!target || !installed || !teamDefs || carriesResolvedValue(target, teamDefs, installed.keys())) held.push(file); + const raw = (await readFileSafe(file)) ?? ''; + if (!target || !installed || !teamDefs || carriesResolvedValue(target, teamDefs, installed.keys()) + || values.some((value) => raw.includes(value))) held.push(file); } return held; } @@ -777,7 +786,7 @@ async function buildRemovalPlan( const dirs: string[] = []; for (const cfg of await projectWorktreeConfigs(localConfig)) { if (cfg.projectRoot) dirs.push(cfg.projectRoot); - for (const target of await resolveMcpTargets(teamConfig, cfg)) dirs.push(path.dirname(target.file)); + for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) dirs.push(path.dirname(target.file)); } plan.gitExcludes = await findMcpGitExcludes(dirs); } From 0abdc1ebf2bf00313e1266ef28e839bfedef30ef Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 28 Sep 2026 19:48:46 +0200 Subject: [PATCH 06/85] fix(mcp): judge MCP configs by disk and manifest, not current config (#882) Pull, doctor and uninstall still decided "clean" from the current team config in places. Now one function, resolvedValueEvidence, decides for all three: - a teamai-owned entry still in the file counts when its server has left mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot be read (doctor no longer skips that case) - targets include the built-in location of a tool the team dropped from toolPaths or moved - doctor names a file two tools share once - exclude updates take the existing acquireLock helper, re-read the file and write it atomically, so concurrent commands keep each other's paths - uninstall inspects every worktree of each repository owning a block, including a nested repository's linked worktrees, and applies the manifest rule per worktree - the kept-block warning names each file and why, such as the variable whose value matched --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/doctor-mcp-delivery.test.ts | 35 ++++++++++ src/__tests__/doctor.test.ts | 3 +- src/__tests__/mcp-git-exclude.test.ts | 26 +++++++ src/__tests__/mcp-reconcile.test.ts | 20 ++++++ src/__tests__/uninstall.test.ts | 67 ++++++++++++++++++ src/doctor-delivery.ts | 31 ++++----- src/mcp-git-exclude.ts | 84 ++++++++++++++++------- src/mcp-reconcile.ts | 53 +++++++++++--- src/uninstall.ts | 64 +++++++++++------ 11 files changed, 308 insertions(+), 79 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b99d5cf32..92bed0d9a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write, such as one written earlier for a tool since disabled; a path git cannot answer for is listed all the same, or teamai warns. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file the block lists is gone, or holds neither a team server with a resolved value nor the value (8+ characters) of a variable still set in the environment; a file it cannot check (for example one that does not parse) keeps the block, with a warning. `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same, or teamai warns. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file it lists, in every worktree of its repository, is gone or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the block and warns, naming each file and why. `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 e474a8a4b..a9da356aa 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用,例如之前为某个现已禁用的工具写入的文件;git 无法判断的路径也会照样写入,否则 teamai 会给出警告。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件都已不存在,或既不含带解析值的团队 server,也不含仍在环境中设置的变量的值(8 个字符以上);无法检查的文件(例如无法解析)会保留该块并给出警告。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入,否则 teamai 会给出警告。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件(在其仓库的每个 worktree 中)都已不存在,或不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该块并给出警告,列出每个文件及原因。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 9d0353497..0c4380c30 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -291,6 +291,41 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); }); + it.each([ + ['its server has left mcp.yaml and its tool is disabled', async () => { + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + localConfig.disabledAgents = ['claude', 'tclaude']; + }], + ['the team dropped its tool from toolPaths', async () => { + teamConfig.toolPaths = { cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' } }; + }], + ['the team\'s mcp.yaml does not parse', async () => { + await writeTeamMcp('servers: [unclosed\n'); + }], + ])('still fails, naming the file once, when %s', async (_label, arrange) => { + await arrange(); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); + }); + + it('names a file two tools share once', async () => { + teamConfig.toolPaths = { + claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, + codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' }, + }; + vi.stubEnv('JIRA_TOKEN', 'long-t0ken-value-7c1'); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer long-t0ken-value-7c1' } } }, + }); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); + }); + it('still fails when the manifest is gone but the resolved value is in the file', async () => { vi.stubEnv('JIRA_TOKEN', 'long-t0ken-value-7c1'); await fse.writeJson(path.join(projectRoot, '.mcp.json'), { diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 50083d36d..c5e1685fd 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -15,8 +15,9 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../utils/fs.js', () => ({ pathExists: vi.fn(), readFileSafe: vi.fn(), - // Manifest loaders read through this one; no manifest exists on this machine. + // Manifest loaders read through these; no manifest exists on this machine. readFileIfExists: vi.fn().mockResolvedValue(null), + readJson: vi.fn().mockResolvedValue(null), // The delivery checks walk the team repo through resolveDesiredSkills, // resolveDesiredRules, resolveDesiredAgents and DocsHandler. This machine // has none of those; delivery on a real disk is covered by diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index ed28b699b..fabc23f0a 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -21,6 +21,20 @@ vi.mock('../utils/exec.js', async (importOriginal) => { }; }); +// Widens the read-modify-write window on the exclude file, as a slow disk or a second process would. +const slowExcludeRead = vi.hoisted(() => ({ on: false })); +vi.mock('../utils/fs.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSafe: async (file: string) => { + const content = await actual.readFileSafe(file); + if (slowExcludeRead.on && file.endsWith(path.join('info', 'exclude'))) await new Promise((r) => setTimeout(r, 30)); + return content; + }, + }; +}); + import { MCP_EXCLUDE_START, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; import { log } from '../utils/logger.js'; @@ -36,6 +50,7 @@ describe('teamai block in .git/info/exclude (#882)', () => { afterEach(async () => { failCheckIgnore.on = false; + slowExcludeRead.on = false; vi.mocked(log.warn).mockClear(); await fse.remove(repo); }); @@ -61,6 +76,17 @@ describe('teamai block in .git/info/exclude (#882)', () => { }); }); + it('keeps every pattern when several writers add to the same exclude file at once', async () => { + const files = ['a', 'b', 'c', 'd', 'e'].map((name) => path.join(repo, `${name}.json`)); + for (const file of files) await fse.writeJson(file, {}); + slowExcludeRead.on = true; + + await Promise.all(files.map((file) => excludeFromGit(file))); + + const content = await fse.readFile(excludeFile, 'utf8'); + for (const name of ['a', 'b', 'c', 'd', 'e']) expect(content).toMatch(new RegExp(`^/${name}\\.json$`, 'm')); + }); + it('stays quiet outside any repository', async () => { const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); await fse.writeJson(path.join(outside, '.mcp.json'), {}); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 549a8b669..69e5db8d9 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -985,6 +985,26 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('when its server has left mcp.yaml and its tool is disabled', async () => { + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + // Claude's copy was cleaned by this pull, so nothing of teamai's is left to protect there. + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + }); + + it.each([ + ['drops the tool', { claude: TOOL_PATHS.claude }], + ['moves its project MCP file', { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }], + ])('when the team %s', async (_label, toolPaths) => { + await reconcileMcpForConfig({ ...teamConfig, toolPaths } as TeamaiConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + it('when the team\'s mcp.yaml does not parse', async () => { await writeMcpYaml('servers: [unclosed\n'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index a5301f2b7..57bc3118d 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -858,6 +858,33 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); }); + it('names the variable whose value keeps the block', async () => { + const { projectRoot, excludeFile } = await setup(); + vi.stubEnv('TEAM_BASE_URL', 'https://base.example'); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { mine: { url: 'https://base.example/mcp' } } }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('$TEAM_BASE_URL')); + }); + + it('keeps the block while a tool uninstall no longer reaches still holds teamai\'s server', async () => { + const { repoPath, projectRoot, excludeFile, localConfig } = await setup(); + // The server left mcp.yaml, its variable is not set here, and Claude is no longer detected. + await fse.outputFile(path.join(repoPath, 'mcp', 'mcp.yaml'), 'servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + await fse.remove(path.join(projectRoot, '.claude')); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira } }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], + }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + }); + it('removes the block once uninstall has taken teamai\'s servers out of .mcp.json', async () => { const { homeDir, projectRoot, excludeFile, localConfig } = await setup(); // A path and the login name are in the environment and in ordinary configs: neither holds the block. @@ -875,6 +902,46 @@ describe('uninstall', () => { }); }); + it('project-scope uninstall keeps a nested repository\'s block while its linked worktree holds a token (#882)', async () => { + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + const projectRoot = path.join(tmpDir, 'business-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const cursorDir = path.join(projectRoot, '.cursor'); + await fse.ensureDir(path.join(cursorDir, 'skills')); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + execFileSync('git', ['init', '-q'], { cwd: cursorDir }); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'], { cwd: cursorDir }); + const linked = path.join(tmpDir, 'cursor-linked'); + execFileSync('git', ['worktree', 'add', '-q', linked], { cwd: cursorDir }); + await fse.writeJson(path.join(linked, 'mcp.json'), { mcpServers: { jira: { headers: { Authorization: 'Bearer t0ken' } } } }); + const excludeFile = path.join(cursorDir, '.git', 'info', 'exclude'); + const block = [ + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n'); + await fse.writeFile(excludeFile, block); + + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + const teamConfig = makeTeamConfig({ + toolPaths: { cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' } }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + }); + it('project-scope uninstall removes the block from a nested repository holding an MCP config (#882)', async () => { const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(tmpDir, 'team-repo'); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 539db54d8..6e454e288 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -510,46 +510,39 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise teamMcpToDef(entry.entry)); + const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); let manifest: ManagedMcpManifest | undefined; let vars: Record | undefined; + const holding = new Set(); const tracked: string[] = []; - let holding = 0; - // Every tool's file, delivery on or off: a disabled or undetected tool's file keeps what a pull wrote. + // Every tool's file, delivery on or off, the same files and evidence pull protects. Two tools may share one. for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { - const raw = await readFileSafe(target.file); - if (raw === null) continue; - const installed = await installedMcpEntries(target); - // Only servers teamai owns: a member's own server under a team name holds no value teamai resolved. - // A file that does not parse is judged by what the manifest says teamai put there, and the value - // itself is found without the manifest, which can be lost. + if (holding.has(target.file) || !await pathExists(target.file)) continue; manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; + vars ??= await buildVarTable(localConfig); const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - if (!carriesResolvedValue(target, teamDefs, installed ? owned.filter((name) => installed.has(name)) : owned)) { - vars ??= await buildVarTable(localConfig); - if (!holdsResolvedValue(target, teamDefs, vars, raw)) continue; - } - holding += 1; + if (!await resolvedValueEvidence(target, teamDefs, owned, vars)) continue; + holding.add(target.file); const tracking = await gitTracking(target.file); if (tracking.kind === 'would-commit') tracked.push(target.file); else if (tracking.kind === 'unknown') tracked.push(`${target.file} (git failed: ${tracking.error})`); } - if (holding === 0) return []; + if (holding.size === 0) return []; return [{ name: 'Project MCP configs with resolved values are kept out of git', source: 'local', check: async () => tracked.length === 0, - fix: `${tracked.join(', ')} hold MCP variables resolved to plaintext, and git would commit them or cannot say. ` + fix: `${tracked.join(', ')} may hold MCP variables resolved to plaintext, and git would commit them or cannot say. ` + 'Fix any git error shown, then run `teamai pull` to list them in .git/info/exclude. If git already tracks one, run ' + '`git rm --cached ` and rotate the values it held.', }]; diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index d24231fb1..086a18360 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -4,7 +4,8 @@ import type { McpServerDef } from './types.js'; import type { McpTarget } from './mcp-reconcile.js'; import { referencedVars, supportsEnvExpansion } from './resources/mcp-format.js'; import { execCommand } from './utils/exec.js'; -import { pathExists, readFileSafe } from './utils/fs.js'; +import { pathExists, readFileSafe, writeFileAtomic } from './utils/fs.js'; +import { listWorktrees } from './utils/git.js'; import { log } from './utils/logger.js'; // ─── Project MCP configs and git ───────────────────────────── @@ -36,23 +37,27 @@ export function carriesResolvedValue( } /** - * Whether `raw`, a project file's text, holds a value teamai resolves into - * `target`: the value in `vars` (8+ characters, shorter ones turn up anywhere) - * of a variable one of `teamDefs` references and the tool does not expand - * itself. Needs no ownership manifest. + * The variable whose value, resolved by teamai into `target`, `raw` (a project + * file's text) holds, or null: one `teamDefs` references that the tool does not + * expand itself, with a value in `vars` of 8+ characters (shorter ones turn up + * anywhere). Needs no ownership manifest. */ -export function holdsResolvedValue( +export function resolvedVariableIn( target: McpTarget, teamDefs: McpServerDef[], vars: Record, raw: string, -): boolean { - if (!target.projectScope) return false; - return teamDefs.some((def) => !supportsEnvExpansion(target.format, target.projectScope, def) - && referencedVars(def).some((name) => { +): string | null { + if (!target.projectScope) return null; + for (const def of teamDefs) { + if (supportsEnvExpansion(target.format, target.projectScope, def)) continue; + const found = referencedVars(def).find((name) => { const value = vars[name]; return value !== undefined && value.length >= 8 && raw.includes(value); - })); + }); + if (found) return found; + } + return null; } /** @@ -132,15 +137,16 @@ export async function excludeFromGit(file: string): Promise { // Anchored at the working tree root, glob characters escaped. const pattern = `/${location.prefix}${path.basename(file)}`.replace(/[\\*?[\]!#]/g, '\\$&'); try { - const content = (await readFileSafe(excludeFile)) ?? ''; - const block = splitBlock(content); - if (block?.patterns.includes(pattern)) return; - const head = block ? block.before : content; - const patterns = [...(block?.patterns ?? []), pattern]; - const body = [MCP_EXCLUDE_START, ...patterns, MCP_EXCLUDE_END].join('\n'); - const sep = head === '' || head.endsWith('\n') ? '' : '\n'; - await fse.outputFile(excludeFile, `${head}${sep}${body}\n${block?.after ?? ''}`); - log.debug(`Added ${pattern} to ${excludeFile}`); + const added = await updateExclude(excludeFile, (content) => { + const block = splitBlock(content); + if (block?.patterns.includes(pattern)) return null; + const head = block ? block.before : content; + const patterns = [...(block?.patterns ?? []), pattern]; + const body = [MCP_EXCLUDE_START, ...patterns, MCP_EXCLUDE_END].join('\n'); + const sep = head === '' || head.endsWith('\n') ? '' : '\n'; + return `${head}${sep}${body}\n${block?.after ?? ''}`; + }); + if (added) log.debug(`Added ${pattern} to ${excludeFile}`); } catch (e) { log.warn( `${file} holds a resolved MCP variable, and adding it to ${excludeFile} failed: ${e instanceof Error ? e.message : String(e)}. ` @@ -149,6 +155,32 @@ export async function excludeFromGit(file: string): Promise { } } +/** + * Rewrite `excludeFile` with `edit` (null: leave it as it is), holding a lock + * across the read and an atomic write: the worktrees of a repository share the + * file, so two commands adding different paths must not drop each other's. + * A lock still held after the wait is passed over rather than skipping the + * write, which would leave the path unprotected. + */ +async function updateExclude(excludeFile: string, edit: (content: string) => string | null): Promise { + const { acquireLock, releaseLock } = await import('./update.js'); + const lockPath = `${excludeFile}.teamai-lock`; + let held = false; + for (let attempt = 0; attempt < 25 && !held; attempt++) { + held = await acquireLock(lockPath); + if (!held) await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!held) log.debug(`${lockPath} is still held; updating ${excludeFile} without it`); + try { + const next = edit((await readFileSafe(excludeFile)) ?? ''); + if (next === null) return false; + await writeFileAtomic(excludeFile, next); + return true; + } finally { + if (held) await releaseLock(lockPath); + } +} + /** * The `.git/info/exclude` files holding teamai's block, one per repository * among those `dirs` are in (a config inside a nested repository or submodule @@ -168,6 +200,9 @@ export async function findMcpGitExcludes(dirs: Iterable): Promise`, glob characters escaped (see excludeFromGit). const rels = block.patterns.map((p) => p.replace(/^\//, '').replace(/\\(.)/g, '$1')); found.set(excludeFile, [...checkouts].flatMap((root) => rels.map((rel) => path.join(root, rel)))); @@ -177,9 +212,8 @@ export async function findMcpGitExcludes(dirs: Iterable): Promise { - const content = await readFileSafe(excludeFile); - const block = content === null ? null : splitBlock(content); - if (!block) return false; - await fse.writeFile(excludeFile, block.before + block.after); - return true; + return updateExclude(excludeFile, (content) => { + const block = splitBlock(content); + return block ? block.before + block.after : null; + }); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index a34c01f3e..2c311d0d3 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -17,6 +17,7 @@ import { managedMcpManifestKey, resolveToolBaseDir, scopedToolPaths, + TeamaiConfigSchema, } from './types.js'; import { detectMcpFormat, @@ -44,7 +45,7 @@ import { import { log } from './utils/logger.js'; import { loadProjectMcpManifest } from './utils/mcp-manifest.js'; import { isOnPath, SAFE_BIN_RE, type LookPathOptions } from './utils/lookpath.js'; -import { carriesResolvedValue, excludeFromGit, holdsResolvedValue } from './mcp-git-exclude.js'; +import { carriesResolvedValue, excludeFromGit, resolvedVariableIn } from './mcp-git-exclude.js'; // ─── Reconcile engine ──────────────────────────────────────── // @@ -216,7 +217,11 @@ export interface McpTarget { export async function resolveMcpTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, - /** Also the tools not detected here: a file an earlier pull wrote outlives its tool. */ + /** + * Also the tools not detected here, and in project scope the built-in + * location of a tool the team dropped or moved: a file an earlier pull + * wrote outlives its tool and its mapping. + */ options: { includeUndetected?: boolean } = {}, ): Promise { const projectScope = localConfig.scope === 'project'; @@ -224,7 +229,14 @@ export async function resolveMcpTargets( // Skills/settings/agents probe paths must reflect the active scope: OpenCode's // user-scope resources live under ~/.config/opencode, not ~/.opencode. - for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + const toolPaths = scopedToolPaths(teamConfig, localConfig); + const entries = Object.entries(toolPaths); + if (options.includeUndetected && projectScope) { + for (const [tool, paths] of Object.entries(TeamaiConfigSchema.shape.toolPaths.parse(undefined))) { + if (paths.mcpProject && toolPaths[tool]?.mcpProject !== paths.mcpProject) entries.push([tool, paths]); + } + } + for (const [tool, paths] of entries) { const format = detectMcpFormat(tool); if (!format) continue; @@ -492,6 +504,32 @@ export async function installedMcpEntries(target: McpTarget): Promise, +): Promise { + const raw = await readFileSafe(target.file); + if (raw === null) return null; + const installed = await installedMcpEntries(target); + const present = installed ? owned.filter((name) => installed.has(name)) : owned; + if (!teamDefs) return present.length > 0 ? `teamai's ${present.join(', ')}, and the team's MCP servers cannot be read` : null; + const dropped = present.find((name) => !teamDefs.some((def) => def.name === name)); + if (dropped) return `teamai's ${dropped}, which has left the team's MCP servers`; + const needing = present.find((name) => carriesResolvedValue(target, teamDefs, [name])); + if (needing) return `teamai's ${needing}, which needs a resolved \${VAR}`; + const variable = resolvedVariableIn(target, teamDefs, vars, raw); + return variable ? `the value of $${variable}` : null; +} + // ─── Main entry ────────────────────────────────────────────── export function mcpTargetExcluded(localConfig: LocalConfig, target: McpTarget): boolean { @@ -541,15 +579,10 @@ async function protectProjectMcpConfigs(teamConfig: TeamaiConfig, localConfig: L const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); - const vars = teamDefs ? await buildVarTable(localConfig) : {}; + const vars = await buildVarTable(localConfig); for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - // Team servers that cannot be read say nothing either way: any teamai entry may hold one. - // The value itself is found without the manifest, which can be lost. - const holds = teamDefs - ? carriesResolvedValue(target, teamDefs, owned) || holdsResolvedValue(target, teamDefs, vars, (await readFileSafe(target.file)) ?? '') - : owned.length > 0; - if (holds) await excludeFromGit(target.file); + if (await resolvedValueEvidence(target, teamDefs, owned, vars)) await excludeFromGit(target.file); } } diff --git a/src/uninstall.ts b/src/uninstall.ts index e8bb4f643..78d809990 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -20,6 +20,7 @@ import { TEAMAI_ENV_START, TEAMAI_ENV_END, getDataHome, + managedMcpManifestKey, getManagedHooksPath, isAgentExcluded, managedMcpManifestPath, @@ -511,41 +512,57 @@ async function projectWorktreeConfigs(localConfig: LocalConfig): Promise { - const { resolveMcpTargets, installedMcpEntries, buildVarTable } = await import('./mcp-reconcile.js'); +async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: LocalConfig, files: string[]): Promise> { + const { resolveMcpTargets, installedMcpEntries, buildVarTable, resolvedValueEvidence } = await import('./mcp-reconcile.js'); const { carriesResolvedValue } = await import('./mcp-git-exclude.js'); const { mcpEntryReader, teamMcpToDef } = await import('./resources/mcp.js'); const { resolveEntriesFor } = await import('./namespaced-entries.js'); + const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); + const targets = new Map(); for (const cfg of await projectWorktreeConfigs(localConfig)) { + const manifest: ManagedMcpManifest = cfg.projectRoot + ? (await loadProjectMcpManifest(getDataHome(cfg), cfg.projectRoot, { dryRun: true })).manifest + : {}; for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fs.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); - targets.set(path.join(dir, path.basename(target.file)), target); + const key = path.join(dir, path.basename(target.file)); + const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); + targets.set(key, { target, owned: [...targets.get(key)?.owned ?? [], ...owned] }); } } // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. const identity = new Set(['USER', 'LOGNAME', 'USERNAME']); - const values = Object.entries(await buildVarTable(localConfig)) - .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)) - .map(([, value]) => value); - const held: string[] = []; + const vars = await buildVarTable(localConfig); + const values = Object.entries(vars) + .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)); + const held = new Map(); for (const file of files) { if (!await pathExists(file)) continue; - const target = targets.get(file); - const installed = target ? await installedMcpEntries(target) : null; + const known = targets.get(file); + const installed = known ? await installedMcpEntries(known.target) : null; const raw = (await readFileSafe(file)) ?? ''; - if (!target || !installed || !teamDefs || carriesResolvedValue(target, teamDefs, installed.keys()) - || values.some((value) => raw.includes(value))) held.push(file); + const named = known && installed && teamDefs + ? [...installed.keys()].find((name) => carriesResolvedValue(known.target, teamDefs, [name])) + : undefined; + const why = !known ? 'no tool teamai knows reads it' + : !installed ? 'it does not parse' + : !teamDefs ? 'the team\'s MCP servers cannot be read' + : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` + : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) + ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0]; + if (why) held.set(file, why); } return held; } @@ -1308,13 +1325,16 @@ export async function uninstall(opts: UninstallOptions): Promise { // `git add -A` would commit a value teamai resolved. if (plan.gitExcludes.size > 0) { const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); - const held = new Set(await mcpConfigsNotProvenClean(teamConfig, localConfig, [...plan.gitExcludes.values()].flat())); + const held = await mcpConfigsNotProvenClean(teamConfig, localConfig, [...plan.gitExcludes.values()].flat()); for (const [excludeFile, protects] of plan.gitExcludes) { - const still = protects.filter((file) => held.has(file)); + const still = protects.flatMap((file) => { + const why = held.get(file); + return why ? [`${file} (${why})`] : []; + }); if (still.length > 0) { log.warn( - `Kept teamai's block in ${excludeFile}: ${still.join(', ')} may still hold MCP values teamai resolved to plaintext, ` - + 'or could not be read. Remove the team\'s MCP servers from it yourself, then delete the block.', + `Kept teamai's block in ${excludeFile}, since these files may still hold MCP values teamai resolved to plaintext: ` + + `${still.join('; ')}. Remove any such value, or confirm the file is safe to commit, then delete the block yourself.`, ); } else if (await removeMcpGitExclude(excludeFile)) { log.info(`Removed teamai's MCP config entries from ${excludeFile}`); From d8da5673ab544e258fa1242ac28caad2fdec881f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 01:41:54 +0200 Subject: [PATCH 07/85] fix(mcp): skip the .git/info/exclude write while another command holds its lock (#882) After the 2.5 s wait for the exclude file's lock, updateExclude wrote without it, so two writers could drop each other's pattern and leave a plaintext MCP config committable. It now writes nothing and reports 'locked': pull warns that the file is not excluded yet and to run `teamai pull` again (doctor's exclude check keeps reporting it meanwhile), and uninstall keeps the block and warns. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-git-exclude.test.ts | 35 +++++++++++++++++++++++++-- src/mcp-git-exclude.ts | 29 ++++++++++++++-------- src/uninstall.ts | 8 ++++-- 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 92bed0d9a..cb08d3f55 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same, or teamai warns. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file it lists, in every worktree of its repository, is gone or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the block and warns, naming each file and why. `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same, or teamai warns; so does a pull that finds another teamai command holding the exclude file past a short wait, which writes nothing (run `teamai pull` again). The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file it lists, in every worktree of its repository, is gone or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the block and warns, naming each file and why. `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 a9da356aa..cc1745e1a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入,否则 teamai 会给出警告。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件(在其仓库的每个 worktree 中)都已不存在,或不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该块并给出警告,列出每个文件及原因。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入,否则 teamai 会给出警告;若 pull 在短暂等待后仍发现另一个 teamai 命令占用该 exclude 文件,则不写入并给出警告(请再次运行 `teamai pull`)。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件(在其仓库的每个 worktree 中)都已不存在,或不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该块并给出警告,列出每个文件及原因。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index fabc23f0a..b580db4e8 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -35,7 +35,8 @@ vi.mock('../utils/fs.js', async (importOriginal) => { }; }); -import { MCP_EXCLUDE_START, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; +import { MCP_EXCLUDE_END, MCP_EXCLUDE_START, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; +import { acquireLock, releaseLock } from '../update.js'; import { log } from '../utils/logger.js'; describe('teamai block in .git/info/exclude (#882)', () => { @@ -87,6 +88,36 @@ describe('teamai block in .git/info/exclude (#882)', () => { for (const name of ['a', 'b', 'c', 'd', 'e']) expect(content).toMatch(new RegExp(`^/${name}\\.json$`, 'm')); }); + describe('while another command holds the exclude file\'s lock', () => { + beforeEach(async () => { + expect(await acquireLock(`${excludeFile}.teamai-lock`)).toBe(true); + }); + + afterEach(async () => { + await releaseLock(`${excludeFile}.teamai-lock`); + }); + + it('does not write, and warns that the file is not excluded yet and to pull again', async () => { + await fse.outputFile(excludeFile, 'scratch/\n'); + await fse.writeJson(path.join(repo, '.mcp.json'), {}); + + await excludeFromGit(path.join(repo, '.mcp.json')); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe('scratch/\n'); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(repo, '.mcp.json'))); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('teamai pull')); + }); + + it('does not remove the block', async () => { + const content = `${MCP_EXCLUDE_START}\n/.mcp.json\n${MCP_EXCLUDE_END}\n`; + await fse.outputFile(excludeFile, content); + + expect(await removeMcpGitExclude(excludeFile)).toBe('locked'); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(content); + }); + }); + it('stays quiet outside any repository', async () => { const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); await fse.writeJson(path.join(outside, '.mcp.json'), {}); @@ -102,7 +133,7 @@ describe('teamai block in .git/info/exclude (#882)', () => { await fse.writeJson(path.join(repo, '.mcp.json'), {}); await excludeFromGit(path.join(repo, '.mcp.json')); - expect(await removeMcpGitExclude(excludeFile)).toBe(true); + expect(await removeMcpGitExclude(excludeFile)).toBe('written'); expect(await fse.readFile(excludeFile, 'utf8')).toBe(`${MCP_EXCLUDE_START}\n/old.json\nscratch/\n`); }); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 086a18360..8320ff6a3 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -137,7 +137,7 @@ export async function excludeFromGit(file: string): Promise { // Anchored at the working tree root, glob characters escaped. const pattern = `/${location.prefix}${path.basename(file)}`.replace(/[\\*?[\]!#]/g, '\\$&'); try { - const added = await updateExclude(excludeFile, (content) => { + const result = await updateExclude(excludeFile, (content) => { const block = splitBlock(content); if (block?.patterns.includes(pattern)) return null; const head = block ? block.before : content; @@ -146,7 +146,13 @@ export async function excludeFromGit(file: string): Promise { const sep = head === '' || head.endsWith('\n') ? '' : '\n'; return `${head}${sep}${body}\n${block?.after ?? ''}`; }); - if (added) log.debug(`Added ${pattern} to ${excludeFile}`); + if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); + if (result === 'locked') { + log.warn( + `${file} holds a resolved MCP variable and is not excluded from git yet: another teamai command held ${excludeFile} past the wait. ` + + 'Run `teamai pull` again, and do not commit the file meanwhile.', + ); + } } catch (e) { log.warn( `${file} holds a resolved MCP variable, and adding it to ${excludeFile} failed: ${e instanceof Error ? e.message : String(e)}. ` @@ -155,14 +161,17 @@ export async function excludeFromGit(file: string): Promise { } } +/** How `updateExclude` left the file: `locked` wrote nothing, another command held it past the wait. */ +export type ExcludeUpdate = 'written' | 'unchanged' | 'locked'; + /** * Rewrite `excludeFile` with `edit` (null: leave it as it is), holding a lock * across the read and an atomic write: the worktrees of a repository share the * file, so two commands adding different paths must not drop each other's. - * A lock still held after the wait is passed over rather than skipping the - * write, which would leave the path unprotected. + * A lock still held after the wait writes nothing: an unlocked write could drop + * the holder's pattern, leaving that path unprotected. */ -async function updateExclude(excludeFile: string, edit: (content: string) => string | null): Promise { +async function updateExclude(excludeFile: string, edit: (content: string) => string | null): Promise { const { acquireLock, releaseLock } = await import('./update.js'); const lockPath = `${excludeFile}.teamai-lock`; let held = false; @@ -170,14 +179,14 @@ async function updateExclude(excludeFile: string, edit: (content: string) => str held = await acquireLock(lockPath); if (!held) await new Promise((resolve) => setTimeout(resolve, 100)); } - if (!held) log.debug(`${lockPath} is still held; updating ${excludeFile} without it`); + if (!held) return 'locked'; try { const next = edit((await readFileSafe(excludeFile)) ?? ''); - if (next === null) return false; + if (next === null) return 'unchanged'; await writeFileAtomic(excludeFile, next); - return true; + return 'written'; } finally { - if (held) await releaseLock(lockPath); + await releaseLock(lockPath); } } @@ -211,7 +220,7 @@ export async function findMcpGitExcludes(dirs: Iterable): Promise { +export async function removeMcpGitExclude(excludeFile: string): Promise { return updateExclude(excludeFile, (content) => { const block = splitBlock(content); return block ? block.before + block.after : null; diff --git a/src/uninstall.ts b/src/uninstall.ts index 78d809990..907bde003 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1336,8 +1336,12 @@ export async function uninstall(opts: UninstallOptions): Promise { `Kept teamai's block in ${excludeFile}, since these files may still hold MCP values teamai resolved to plaintext: ` + `${still.join('; ')}. Remove any such value, or confirm the file is safe to commit, then delete the block yourself.`, ); - } else if (await removeMcpGitExclude(excludeFile)) { - log.info(`Removed teamai's MCP config entries from ${excludeFile}`); + continue; + } + const result = await removeMcpGitExclude(excludeFile); + if (result === 'written') log.info(`Removed teamai's MCP config entries from ${excludeFile}`); + if (result === 'locked') { + log.warn(`Kept teamai's block in ${excludeFile}: another teamai command held it past the wait. Delete the block yourself.`); } } } From b1a79731618629d45e0ed27f530a788b54efd1b7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 01:42:27 +0200 Subject: [PATCH 08/85] fix(uninstall): keep an exclude entry unless its MCP config is proven free of teamai's servers (#882) Uninstall judged a protected file clean from the current mcp.yaml, manifest and resolvable values, so with the manifest lost, the server gone from mcp.yaml and its value unset, a plaintext token looked like the member's own server and the exclusion went. It now fails closed and works per entry: a pattern goes only when its file is gone, holds no server, or holds none of teamai's servers with managed-mcp.json still there to say what teamai wrote. A kept entry is named with its file, why, and how to clean it by hand, since a rerun of uninstall finds no config after a full uninstall. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-git-exclude.test.ts | 16 ++++-- src/__tests__/uninstall.test.ts | 48 ++++++++++++++++- src/mcp-git-exclude.ts | 25 ++++++--- src/uninstall.ts | 74 ++++++++++++++++----------- 6 files changed, 123 insertions(+), 44 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cb08d3f55..f6de24f1e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same, or teamai warns; so does a pull that finds another teamai command holding the exclude file past a short wait, which writes nothing (run `teamai pull` again). The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes the block once every file it lists, in every worktree of its repository, is gone or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the block and warns, naming each file and why. `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same, or teamai warns; so does a pull that finds another teamai command holding the exclude file past a short wait, which writes nothing (run `teamai pull` again). The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) still present. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the path and warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 cc1745e1a..7d8e339ce 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入,否则 teamai 会给出警告;若 pull 在短暂等待后仍发现另一个 teamai 命令占用该 exclude 文件,则不写入并给出警告(请再次运行 `teamai pull`)。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会移除该块,前提是该块列出的每个文件(在其仓库的每个 worktree 中)都已不存在,或不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该块并给出警告,列出每个文件及原因。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入,否则 teamai 会给出警告;若 pull 在短暂等待后仍发现另一个 teamai 命令占用该 exclude 文件,则不写入并给出警告(请再次运行 `teamai pull`)。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在 teamai 的写入记录(`managed-mcp.json`)仍在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该路径并给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index b580db4e8..94d3d32e4 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -108,16 +108,26 @@ describe('teamai block in .git/info/exclude (#882)', () => { expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('teamai pull')); }); - it('does not remove the block', async () => { + it('does not remove patterns', async () => { const content = `${MCP_EXCLUDE_START}\n/.mcp.json\n${MCP_EXCLUDE_END}\n`; await fse.outputFile(excludeFile, content); - expect(await removeMcpGitExclude(excludeFile)).toBe('locked'); + expect(await removeMcpGitExclude(excludeFile, ['/.mcp.json'])).toBe('locked'); expect(await fse.readFile(excludeFile, 'utf8')).toBe(content); }); }); + it('removes only the patterns asked for, and the block with its last one', async () => { + await fse.outputFile(excludeFile, `mine/\n${MCP_EXCLUDE_START}\n/a.json\n/b.json\n${MCP_EXCLUDE_END}\n`); + + expect(await removeMcpGitExclude(excludeFile, ['/a.json'])).toBe('written'); + expect(await fse.readFile(excludeFile, 'utf8')).toBe(`mine/\n${MCP_EXCLUDE_START}\n/b.json\n${MCP_EXCLUDE_END}\n`); + + expect(await removeMcpGitExclude(excludeFile, ['/b.json'])).toBe('written'); + expect(await fse.readFile(excludeFile, 'utf8')).toBe('mine/\n'); + }); + it('stays quiet outside any repository', async () => { const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); await fse.writeJson(path.join(outside, '.mcp.json'), {}); @@ -133,7 +143,7 @@ describe('teamai block in .git/info/exclude (#882)', () => { await fse.writeJson(path.join(repo, '.mcp.json'), {}); await excludeFromGit(path.join(repo, '.mcp.json')); - expect(await removeMcpGitExclude(excludeFile)).toBe('written'); + expect(await removeMcpGitExclude(excludeFile, ['/.mcp.json'])).toBe('written'); expect(await fse.readFile(excludeFile, 'utf8')).toBe(`${MCP_EXCLUDE_START}\n/old.json\nscratch/\n`); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 57bc3118d..5b7e9e0a4 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -796,7 +796,7 @@ describe('uninstall', () => { await uninstall({ force: true }); expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept teamai's block in ${await fse.realpath(excludeFile)}`)); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept \`/.mcp.json\` in ${await fse.realpath(excludeFile)}`)); }); describe('the block protects a config holding a resolved value (#882)', () => { @@ -843,7 +843,7 @@ describe('uninstall', () => { expect(await fse.readJson(path.join(projectRoot, '.mcp.json'))).toEqual({ mcpServers: { jira } }); expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept teamai's block in ${await fse.realpath(excludeFile)}`)); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept \`/.mcp.json\` in ${await fse.realpath(excludeFile)}`)); }); it('keeps the block when a server dropped from mcp.yaml left its token behind with no manifest', async () => { @@ -858,6 +858,50 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); }); + it('keeps the entry, naming the file, when the manifest is lost, the server left mcp.yaml and its value is not set', async () => { + const { repoPath, projectRoot, excludeFile } = await setup(); + await fse.outputFile(path.join(repoPath, 'mcp', 'mcp.yaml'), 'servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira } }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + const warning = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)).find((m) => m.includes('/.mcp.json')); + expect(warning).toContain(path.join(await fse.realpath(projectRoot), '.mcp.json')); + expect(warning).toContain(await fse.realpath(excludeFile)); + }); + + it('removes the entry when the file holds no server, with no manifest', async () => { + const { projectRoot, excludeFile } = await setup(); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: {} }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(''); + }); + + it('removes only the entry whose file is gone', async () => { + const { projectRoot, excludeFile } = await setup(); + await fse.writeFile(excludeFile, [ + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/.mcp.json', + '/other.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n')); + await fse.writeJson(path.join(projectRoot, 'other.json'), { mcpServers: { jira } }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe([ + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/other.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n')); + }); + it('names the variable whose value keeps the block', async () => { const { projectRoot, excludeFile } = await setup(); vi.stubEnv('TEAM_BASE_URL', 'https://base.example'); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 8320ff6a3..b73db767f 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -194,9 +194,9 @@ async function updateExclude(excludeFile: string, edit: (content: string) => str * The `.git/info/exclude` files holding teamai's block, one per repository * among those `dirs` are in (a config inside a nested repository or submodule * is excluded from that repository, not from the project root's), each with - * the absolute paths its block protects in the checkouts `dirs` reach. + * its patterns and the absolute paths each protects in the checkouts `dirs` reach. */ -export async function findMcpGitExcludes(dirs: Iterable): Promise> { +export async function findMcpGitExcludes(dirs: Iterable): Promise>> { const roots = new Map>(); for (const dir of new Set(dirs)) { const location = await gitExcludeFile(dir); @@ -204,7 +204,7 @@ export async function findMcpGitExcludes(dirs: Iterable): Promise(); roots.set(location.excludeFile, seen.add(location.root)); } - const found = new Map(); + const found = new Map>(); for (const [excludeFile, checkouts] of roots) { const content = await readFileSafe(excludeFile); const block = content === null ? null : splitBlock(content); @@ -213,16 +213,25 @@ export async function findMcpGitExcludes(dirs: Iterable): Promise`, glob characters escaped (see excludeFromGit). - const rels = block.patterns.map((p) => p.replace(/^\//, '').replace(/\\(.)/g, '$1')); - found.set(excludeFile, [...checkouts].flatMap((root) => rels.map((rel) => path.join(root, rel)))); + found.set(excludeFile, block.patterns.map((pattern) => { + const rel = pattern.replace(/^\//, '').replace(/\\(.)/g, '$1'); + return { pattern, files: [...checkouts].map((root) => path.join(root, rel)) }; + })); } return found; } -/** Remove teamai's block from `excludeFile`, one `findMcpGitExcludes` returned. */ -export async function removeMcpGitExclude(excludeFile: string): Promise { +/** + * Remove `patterns` from teamai's block in `excludeFile` (one `findMcpGitExcludes` + * returned), and the block with its last pattern. + */ +export async function removeMcpGitExclude(excludeFile: string, patterns: string[]): Promise { return updateExclude(excludeFile, (content) => { const block = splitBlock(content); - return block ? block.before + block.after : null; + if (!block) return null; + const kept = block.patterns.filter((p) => !patterns.includes(p)); + if (kept.length === block.patterns.length) return null; + const body = kept.length > 0 ? `${[MCP_EXCLUDE_START, ...kept, MCP_EXCLUDE_END].join('\n')}\n` : ''; + return block.before + body + block.after; }); } diff --git a/src/uninstall.ts b/src/uninstall.ts index 907bde003..889f12462 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -116,8 +116,8 @@ interface RemovalPlan { shellProfiles: string[]; /** Docs directory (null if doesn't exist). */ docsDir: string | null; - /** The .git/info/exclude files holding teamai's MCP config block (#882), each with the paths it protects. */ - gitExcludes: Map; + /** The .git/info/exclude files holding teamai's MCP config block (#882), each with its patterns and the paths each protects. */ + gitExcludes: Map>; /** The .teamai home directory path. */ teamaiHome: string; /** Whether teamaiHome exists on disk. */ @@ -513,13 +513,15 @@ async function projectWorktreeConfigs(localConfig: LocalConfig): Promise> { const { resolveMcpTargets, installedMcpEntries, buildVarTable, resolvedValueEvidence } = await import('./mcp-reconcile.js'); @@ -530,16 +532,22 @@ async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: L const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); + const targets = new Map(); for (const cfg of await projectWorktreeConfigs(localConfig)) { - const manifest: ManagedMcpManifest = cfg.projectRoot - ? (await loadProjectMcpManifest(getDataHome(cfg), cfg.projectRoot, { dryRun: true })).manifest - : {}; + let manifest: ManagedMcpManifest = {}; + let recorded = false; + if (cfg.projectRoot) { + const loaded = await loadProjectMcpManifest(getDataHome(cfg), cfg.projectRoot, { dryRun: true }); + manifest = loaded.manifest; + recorded = Object.keys(manifest).length > 0 || await pathExists(loaded.manifestPath); + } for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fs.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - targets.set(key, { target, owned: [...targets.get(key)?.owned ?? [], ...owned] }); + // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. + const seen = targets.get(key); + targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], recorded: recorded || seen?.recorded === true }); } } // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. @@ -558,10 +566,12 @@ async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: L : undefined; const why = !known ? 'no tool teamai knows reads it' : !installed ? 'it does not parse' + : installed.size === 0 ? undefined : !teamDefs ? 'the team\'s MCP servers cannot be read' : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) - ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0]; + ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] + ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone'); if (why) held.set(file, why); } return held; @@ -946,7 +956,7 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { if (plan.gitExcludes.size > 0) { console.log(' Git exclude entries for MCP configs (teamai\'s block):'); - for (const file of plan.gitExcludes.keys()) console.log(` ${file}`); + for (const [file, entries] of plan.gitExcludes) console.log(` ${file} (${entries.map((entry) => entry.pattern).join(', ')})`); console.log(''); } @@ -1325,23 +1335,29 @@ export async function uninstall(opts: UninstallOptions): Promise { // `git add -A` would commit a value teamai resolved. if (plan.gitExcludes.size > 0) { const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); - const held = await mcpConfigsNotProvenClean(teamConfig, localConfig, [...plan.gitExcludes.values()].flat()); - for (const [excludeFile, protects] of plan.gitExcludes) { - const still = protects.flatMap((file) => { - const why = held.get(file); - return why ? [`${file} (${why})`] : []; - }); - if (still.length > 0) { + const allFiles = [...plan.gitExcludes.values()].flatMap((entries) => entries.flatMap((entry) => entry.files)); + const held = await mcpConfigsNotProvenClean(teamConfig, localConfig, allFiles); + for (const [excludeFile, entries] of plan.gitExcludes) { + const clean: string[] = []; + for (const { pattern, files } of entries) { + const still = files.flatMap((file) => { + const why = held.get(file); + return why ? [`${file} (${why})`] : []; + }); + if (still.length === 0) { + clean.push(pattern); + continue; + } log.warn( - `Kept teamai's block in ${excludeFile}, since these files may still hold MCP values teamai resolved to plaintext: ` - + `${still.join('; ')}. Remove any such value, or confirm the file is safe to commit, then delete the block yourself.`, + `Kept \`${pattern}\` in ${excludeFile}, so git still ignores ${still.join('; ')}: it may hold MCP values teamai resolved to plaintext. ` + + `Remove teamai's MCP servers from it (or delete the file), then delete that line from ${excludeFile} yourself, and the block's two marker lines with its last one.`, ); - continue; } - const result = await removeMcpGitExclude(excludeFile); - if (result === 'written') log.info(`Removed teamai's MCP config entries from ${excludeFile}`); + if (clean.length === 0) continue; + const result = await removeMcpGitExclude(excludeFile, clean); + if (result === 'written') log.info(`Removed teamai's MCP config entries ${clean.join(', ')} from ${excludeFile}`); if (result === 'locked') { - log.warn(`Kept teamai's block in ${excludeFile}: another teamai command held it past the wait. Delete the block yourself.`); + log.warn(`Kept teamai's block in ${excludeFile}: another teamai command held it past the wait. Delete the block's ${clean.join(', ')} lines yourself.`); } } } From 38a8fb25c920a9bb50a6e31dcb6be5ed60e7f2ef Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 02:26:22 +0200 Subject: [PATCH 09/85] fix(mcp): exclude a project MCP config from git before writing a resolved value into it (#882) Pull listed the file in .git/info/exclude only after writing the plaintext, and a failed exclusion only warned, so the secret-bearing file stayed eligible for git add -A. The exclusion now comes first; when it cannot be established (exclude file or .git/info not writable, lock held past the wait, file already tracked, git error) the file is left as it was and the warning names the reason and the fix. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-reconcile.test.ts | 108 +++++++++++++++++++++++++ src/mcp-git-exclude.ts | 121 ++++++++++++++++++++-------- src/mcp-reconcile.ts | 42 ++++++++-- 5 files changed, 234 insertions(+), 41 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f6de24f1e..989c2b4b5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file holds a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it). That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same, or teamai warns; so does a pull that finds another teamai command holding the exclude file past a short wait, which writes nothing (run `teamai pull` again). The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) still present. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the path and warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) still present. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the path and warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 7d8e339ce..cef8cce91 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件含有 teamai 解析出的值且 git 会跟踪它,teamai 就会把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入,否则 teamai 会给出警告;若 pull 在短暂等待后仍发现另一个 teamai 命令占用该 exclude 文件,则不写入并给出警告(请再次运行 `teamai pull`)。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在 teamai 的写入记录(`managed-mcp.json`)仍在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该路径并给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在 teamai 的写入记录(`managed-mcp.json`)仍在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该路径并给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 69e5db8d9..d01a23f89 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -21,7 +21,26 @@ vi.mock('../utils/logger.js', () => ({ })), })); +// What .git/info/exclude held at the moment each JSON config was written (#882). +const excludeAtWrite = vi.hoisted(() => new Map()); +vi.mock('../utils/fs.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeJsonAtomic: async (...args: Parameters) => { + const [file] = args; + const gitDir = path.join(path.dirname(String(file)), '.git'); + if (await fse.pathExists(gitDir)) { + excludeAtWrite.set(String(file), await actual.readFileSafe(path.join(gitDir, 'info', 'exclude'))); + } + return actual.writeJsonAtomic(...args); + }, + }; +}); + import { reconcileMcpForConfig, resolveMcpTargets, spliceCodexBlock, codexServerNames } from '../mcp-reconcile.js'; +import { acquireLock, releaseLock } from '../update.js'; +import { log } from '../utils/logger.js'; import { resetWarnOnce } from '../utils/warn-once.js'; import { TeamaiConfigSchema, type TeamaiConfig, type LocalConfig } from '../types.js'; @@ -1014,6 +1033,95 @@ servers: }); }); + it('lists the config in .git/info/exclude before writing the value into it', async () => { + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(excludeAtWrite.get(path.join(projectRoot, '.mcp.json'))).toMatch(/^\/\.mcp\.json$/m); + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + }); + + describe('when the config cannot be kept out of git first', () => { + const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); + const infoDir = (): string => path.join(projectRoot, '.git', 'info'); + const claudeOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + beforeEach(() => { + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + await fse.chmod(infoDir(), 0o755); + await fse.chmod(path.join(infoDir(), 'exclude'), 0o644); + }); + + it.skipIf(process.getuid?.() === 0).each([ + ['.git/info/exclude is read-only', () => fse.chmod(path.join(infoDir(), 'exclude'), 0o444)], + ['.git/info is read-only', () => fse.chmod(infoDir(), 0o555)], + ])('writes no value when %s, and warns with the fix', async (_label, lockDown) => { + await writeMcpYaml(withSecret); + await lockDown(); + + const { changes } = await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.pathExists(mcpJson())).toBe(false); + expect(changes).toContainEqual(expect.objectContaining({ tool: 'claude', server: 'with-secret', action: 'skipped' })); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(mcpJson())); + expect(log.warn).toHaveBeenCalledWith(expect.stringMatching(/not writable[\s\S]*teamai pull/)); + }); + + it.skipIf(process.getuid?.() === 0)('keeps an earlier entry as it was', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await fse.writeFile(path.join(infoDir(), 'exclude'), ''); + const before = await fse.readFile(mcpJson(), 'utf-8'); + await writeMcpYaml(withSecret.replace('https://example.com/mcp', 'https://example.com/v2')); + vi.stubEnv('SECRET_TOKEN', 'rotated-secret-value'); + await fse.chmod(infoDir(), 0o555); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toBe(before); + }); + + it('writes no value while another command holds the exclude file\'s lock', async () => { + const lock = path.join(infoDir(), 'exclude.teamai-lock'); + expect(await acquireLock(lock)).toBe(true); + await writeMcpYaml(withSecret); + + try { + await reconcileMcpForConfig(teamConfig, claudeOnly()); + } finally { + await releaseLock(lock); + } + + expect(await fse.pathExists(mcpJson())).toBe(false); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(mcpJson())); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('teamai pull')); + }); + + it('writes no value into a file git already tracks', async () => { + await fse.writeJson(mcpJson(), { mcpServers: {} }); + git(projectRoot, 'add', '.mcp.json'); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).not.toContain('super-secret-value'); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('git rm --cached')); + }); + + it('still writes a config that carries no resolved value', async () => { + await fse.chmod(infoDir(), 0o555); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('https://example.com/open'); + }); + }); + it('leaves .git/info/exclude alone on a dry run', async () => { await writeMcpYaml(withSecret); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index b73db767f..2ba784092 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -75,6 +75,13 @@ async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; root: return { excludeFile: path.resolve(base, gitPath), root, prefix }; } +/** The closest directory above `file` that exists. */ +async function existingAncestor(file: string): Promise { + let dir = path.dirname(path.resolve(file)); + while (!await pathExists(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir); + return dir; +} + /** * Whether git would put a file in a commit. `unknown` is a repository git could * not answer for (unsafe ownership, a bad config): never read it as safe. @@ -87,8 +94,8 @@ export type GitTracking = /** Whether git would put `file` in a commit: tracked, or untracked without an ignore rule. Read-only. */ export async function gitTracking(file: string): Promise { - const dir = path.dirname(file); - const result = await execCommand('git', ['check-ignore', '-q', '--', path.basename(file)], { cwd: dir, timeoutMs: 10_000 }) + const dir = await existingAncestor(file); + const result = await execCommand('git', ['check-ignore', '-q', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) .catch((e: unknown) => ({ code: -1, stdout: '', stderr: e instanceof Error ? e.message : String(e) })); if (result.code === 0) return { kind: 'ignored' }; if (result.code === 1) return { kind: 'would-commit' }; @@ -114,49 +121,97 @@ function splitBlock(content: string): { before: string; patterns: string[]; afte return { before: content.slice(0, start), patterns, after }; } +/** + * Whether `file` is kept out of git, or why teamai could not keep it out and + * what the member does about it. `pending`: a dry run found nothing in the way + * of listing it. + */ +export type GitExclusion = + | { kind: 'excluded' } + | { kind: 'pending' } + | { kind: 'failed'; reason: string; fix: string }; + /** * Add `file` to its repository's `.git/info/exclude` unless git ignores it - * already. Idempotent; a path already ignored, or outside any repository, adds - * nothing, and one git cannot answer for is added all the same. A failure warns - * rather than failing the sync that wrote the file. + * already, and whether git now leaves it out of a commit. Idempotent; a path + * already ignored, or outside any repository, adds nothing, and one git cannot + * answer for is added all the same. `file` need not exist yet: pull calls this + * before writing a resolved value into it. `dryRun` writes nothing and reports + * what would stop the write. */ -export async function excludeFromGit(file: string): Promise { - if (!await pathExists(file)) return; +export async function ensureExcludedFromGit(file: string, options: { dryRun?: boolean } = {}): Promise { const tracking = await gitTracking(file); - if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return; - const location = await gitExcludeFile(path.dirname(file)); + if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return { kind: 'excluded' }; + // `file` and its directory need not exist yet: git is asked from the nearest one that does. + const dir = await existingAncestor(file); + const location = await gitExcludeFile(dir); if (!location) { - const reason = tracking.kind === 'unknown' ? tracking.error : 'git could not locate .git/info/exclude'; - log.warn( - `${file} holds a resolved MCP variable, and teamai could not keep it out of git: ${reason}. ` - + 'Fix the repository, or add the file to its .git/info/exclude yourself, so git does not commit the value.', - ); - return; + return { + kind: 'failed', + reason: tracking.kind === 'unknown' ? tracking.error : 'git could not locate .git/info/exclude', + fix: 'Fix the repository, or add the file to its .git/info/exclude yourself, then run `teamai pull` again.', + }; } const { excludeFile } = location; // Anchored at the working tree root, glob characters escaped. - const pattern = `/${location.prefix}${path.basename(file)}`.replace(/[\\*?[\]!#]/g, '\\$&'); + const rel = path.relative(dir, file).split(path.sep).join('/'); + const pattern = `/${location.prefix}${rel}`.replace(/[\\*?[\]!#]/g, '\\$&'); + const retry = `Make it writable, or add \`${pattern}\` to it yourself, then run \`teamai pull\` again.`; + // A read-only exclude file is the member's choice; the atomic write would replace it all the same. + for (const writable of [path.dirname(excludeFile), ...(await pathExists(excludeFile) ? [excludeFile] : [])]) { + const denied = await fse.access(writable, fse.constants.W_OK).then(() => false, () => true); + if (denied) return { kind: 'failed', reason: `${writable} is not writable`, fix: retry }; + } + const add = (content: string): string | null => { + const block = splitBlock(content); + if (block?.patterns.includes(pattern)) return null; + const head = block ? block.before : content; + const patterns = [...(block?.patterns ?? []), pattern]; + const body = [MCP_EXCLUDE_START, ...patterns, MCP_EXCLUDE_END].join('\n'); + const sep = head === '' || head.endsWith('\n') ? '' : '\n'; + return `${head}${sep}${body}\n${block?.after ?? ''}`; + }; + let result: ExcludeUpdate; try { - const result = await updateExclude(excludeFile, (content) => { - const block = splitBlock(content); - if (block?.patterns.includes(pattern)) return null; - const head = block ? block.before : content; - const patterns = [...(block?.patterns ?? []), pattern]; - const body = [MCP_EXCLUDE_START, ...patterns, MCP_EXCLUDE_END].join('\n'); - const sep = head === '' || head.endsWith('\n') ? '' : '\n'; - return `${head}${sep}${body}\n${block?.after ?? ''}`; - }); - if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); - if (result === 'locked') { - log.warn( - `${file} holds a resolved MCP variable and is not excluded from git yet: another teamai command held ${excludeFile} past the wait. ` - + 'Run `teamai pull` again, and do not commit the file meanwhile.', - ); + if (options.dryRun) { + if (add((await readFileSafe(excludeFile)) ?? '') !== null) return { kind: 'pending' }; + result = 'unchanged'; + } else { + result = await updateExclude(excludeFile, add); } } catch (e) { + return { kind: 'failed', reason: `adding it to ${excludeFile} failed: ${e instanceof Error ? e.message : String(e)}`, fix: retry }; + } + if (result === 'locked') { + return { + kind: 'failed', + reason: `another teamai command held ${excludeFile} past the wait`, + fix: 'Run `teamai pull` again.', + }; + } + if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); + // An exclude rule does not apply to a file git tracks already. + if ((await gitTracking(file)).kind === 'would-commit') { + return { + kind: 'failed', + reason: 'git already tracks it', + fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }; + } + return { kind: 'excluded' }; +} + +/** + * `ensureExcludedFromGit` for a file already on disk, warning when it fails + * rather than failing the sync that wrote the file. + */ +export async function excludeFromGit(file: string): Promise { + if (!await pathExists(file)) return; + const exclusion = await ensureExcludedFromGit(file); + if (exclusion.kind === 'failed') { log.warn( - `${file} holds a resolved MCP variable, and adding it to ${excludeFile} failed: ${e instanceof Error ? e.message : String(e)}. ` - + `Add \`${pattern}\` to that file yourself so git does not commit the value.`, + `${file} holds a resolved MCP variable, and teamai could not keep it out of git: ${exclusion.reason}. ` + + `${exclusion.fix} Do not commit the file meanwhile.`, ); } } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 2c311d0d3..99c5f1c6a 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -45,7 +45,7 @@ import { import { log } from './utils/logger.js'; import { loadProjectMcpManifest } from './utils/mcp-manifest.js'; import { isOnPath, SAFE_BIN_RE, type LookPathOptions } from './utils/lookpath.js'; -import { carriesResolvedValue, excludeFromGit, resolvedVariableIn } from './mcp-git-exclude.js'; +import { carriesResolvedValue, ensureExcludedFromGit, excludeFromGit, resolvedVariableIn, type GitExclusion } from './mcp-git-exclude.js'; // ─── Reconcile engine ──────────────────────────────────────── // @@ -548,11 +548,13 @@ export async function reconcileMcpForConfig( localConfig: LocalConfig, options: McpReconcileOptions = {}, ): Promise { + // Each project config's exclusion from git, established before a resolved value is written into it. + const exclusions = new Map(); try { - return await reconcileTargets(teamConfig, localConfig, options); + return await reconcileTargets(teamConfig, localConfig, options, exclusions); } finally { // Also after a failed write: what earlier pulls wrote is on disk either way. - if (!options.removeAll && !options.dryRun) await protectResolvedMcpConfigs(teamConfig, localConfig); + if (!options.removeAll && !options.dryRun) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions); } } @@ -562,11 +564,15 @@ export async function reconcileMcpForConfig( * run delivered to it: the file of a disabled or undetected tool, or one * written before the team turned delivery off, still holds what a pull wrote. */ -async function protectResolvedMcpConfigs(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { +async function protectResolvedMcpConfigs( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + exclusions: Map, +): Promise { const { projectRoot } = localConfig; if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; try { - await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot); + await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions); } catch (e) { log.warn( `Could not check this project's MCP configs for resolved values to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` @@ -575,12 +581,19 @@ async function protectResolvedMcpConfigs(teamConfig: TeamaiConfig, localConfig: } } -async function protectProjectMcpConfigs(teamConfig: TeamaiConfig, localConfig: LocalConfig, projectRoot: string): Promise { +async function protectProjectMcpConfigs( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + projectRoot: string, + exclusions: Map, +): Promise { const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const vars = await buildVarTable(localConfig); for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { + // Tried before its write this run, and reported there when it failed. + if (exclusions.has(target.file)) continue; const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); if (await resolvedValueEvidence(target, teamDefs, owned, vars)) await excludeFromGit(target.file); } @@ -590,6 +603,7 @@ async function reconcileTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, options: McpReconcileOptions, + exclusions: Map, ): Promise { const changes: McpChange[] = []; let wrote = false; @@ -658,6 +672,22 @@ async function reconcileTargets( const { desired, skipped } = desiredMcpForTarget(target, teamDefs, desiredContext); changes.push(...skipped); + // A resolved value lands only in a file git leaves out of a commit (#882). + // Otherwise the file stays as it was, its manifest entry with it. + if (carriesResolvedValue(target, teamDefs, desired.keys())) { + const exclusion = exclusions.get(target.file) ?? await ensureExcludedFromGit(target.file, { dryRun: options.dryRun }); + exclusions.set(target.file, exclusion); + if (exclusion.kind === 'failed') { + const reason = `${target.file} is not kept out of git: ${exclusion.reason}`; + for (const server of desired.keys()) changes.push({ tool: target.tool, server, action: 'skipped', reason }); + log.warn( + `Did not write ${target.tool}'s MCP servers to ${target.file}: it would hold resolved values, and teamai could not ` + + `keep it out of git first: ${exclusion.reason}. The file is left as it was. ${exclusion.fix}`, + ); + continue; + } + } + if (target.format === 'codex') { wrote = await applyCodex(target, desired, ownedNames, nextRecords, changes, options) || wrote; } else { From 71c27e7b16f68df884f50d882c237764544af3a8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 02:26:22 +0200 Subject: [PATCH 10/85] fix(mcp): report a server withheld from a file git would commit in mcp list and doctor (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 17 +++++++++++++++ src/__tests__/mcp-cmd.test.ts | 26 ++++++++++++++++++++++- src/doctor-delivery.ts | 6 ++++++ src/mcp-cmd.ts | 14 +++++++++--- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 0c4380c30..4d2037900 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -344,6 +344,23 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await excludeCheck()).toBeUndefined(); }); + it.skipIf(process.getuid?.() === 0)('says a server was withheld because its file cannot be kept out of git, and the fix', async () => { + await fse.remove(path.join(projectRoot, '.mcp.json')); + vi.stubEnv('JIRA_TOKEN', 'long-t0ken-value-7c1'); + const excludeFile = path.join(projectRoot, '.git', 'info', 'exclude'); + await fse.chmod(excludeFile, 0o444); + + try { + const check = await mcpCheck(); + expect(await check.check()).toBe(false); + expect(check.fix).toMatch(/\.git\/info\/exclude is not writable/); + expect(check.fix).toContain('teamai pull'); + expect(check.fix).not.toContain('again..'); + } finally { + await fse.chmod(excludeFile, 0o644); + } + }); + it('emits no check when the installed servers carry no resolved value', async () => { await writeTeamMcp('servers:\n - name: jira\n transport: http\n url: https://jira.example/mcp\n'); diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index 1bcf990f6..9b2a8f04c 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -13,6 +13,10 @@ vi.mock('../mcp-reconcile.js', () => ({ resolveMcpTargets: vi.fn().mockResolvedValue([]), buildVarTable: vi.fn().mockResolvedValue({}), })); +vi.mock('../mcp-git-exclude.js', async (importOriginal) => ({ + ...(await importOriginal()), + ensureExcludedFromGit: vi.fn(), +})); vi.mock('../utils/fs.js', () => ({ readJson: vi.fn().mockResolvedValue(null), })); @@ -23,7 +27,8 @@ vi.mock('../utils/logger.js', () => ({ import { autoDetectInit } from '../config.js'; import { resolveEntriesFor } from '../namespaced-entries.js'; import { mcpInject, mcpList } from '../mcp-cmd.js'; -import { reconcileMcpForConfig } from '../mcp-reconcile.js'; +import { reconcileMcpForConfig, resolveMcpTargets } from '../mcp-reconcile.js'; +import { ensureExcludedFromGit } from '../mcp-git-exclude.js'; const mockedAutoDetectInit = autoDetectInit as Mock; const mockedResolve = resolveEntriesFor as Mock; @@ -87,6 +92,25 @@ describe('mcpList', () => { expect(text.match(/roles:/g)).toHaveLength(1); }); + it('says where a server needing a resolved value is withheld because git would commit the file, and the fix (#882)', async () => { + mockedResolve.mockResolvedValue(resolved([ + [{ name: 'jira', transport: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer ${JIRA_TOKEN}' } }, 'mcp/mcp.yaml', null], + ])); + (resolveMcpTargets as Mock).mockResolvedValueOnce([ + { tool: 'claude', format: 'claude', file: '/work/app/.mcp.json', projectScope: true }, + ]); + (ensureExcludedFromGit as Mock).mockResolvedValueOnce({ + kind: 'failed', + reason: '/work/app/.git/info/exclude is not writable', + fix: 'Make it writable, then run `teamai pull` again.', + }); + + const text = await listOutput(); + + expect(ensureExcludedFromGit).toHaveBeenCalledWith('/work/app/.mcp.json', { dryRun: true }); + expect(text).toContain('withheld: claude — /work/app/.git/info/exclude is not writable. Make it writable, then run `teamai pull` again.'); + }); + it('reports a set that cannot be resolved instead of listing part of it', async () => { mockedResolve.mockResolvedValue({ kind: 'failed', diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 6e454e288..942cbfa13 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -430,6 +430,7 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise 0) problems.push(`not injected: ${nameList(absent)}`); if (foreign.length > 0) problems.push(`not the team's definition: ${nameList(foreign)}`); + // Pull writes a resolved value only into a file git leaves out of a commit (#882). + if (carriesResolvedValue(target, teamDefs, [...absent, ...foreign])) { + const exclusion = await ensureExcludedFromGit(target.file, { dryRun: true }); + if (exclusion.kind === 'failed') problems.push(`withheld, as git would commit the file: ${exclusion.reason}. ${exclusion.fix.replace(/\.$/, '')}`); + } } if (blocked.length > 0) problems.push(`skipped: ${nameList(blocked)}`); diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index 5f9a3378f..529354c51 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -7,8 +7,10 @@ import { resolveMcpTargets, buildVarTable, type McpChange, + type McpTarget, } from './mcp-reconcile.js'; import { referencedVars } from './resources/mcp-format.js'; +import { carriesResolvedValue, ensureExcludedFromGit } from './mcp-git-exclude.js'; import { log } from './utils/logger.js'; import type { GlobalOptions } from './types.js'; import { managedMcpManifestPath, managedMcpManifestKey, getDataHome } from './types.js'; @@ -67,10 +69,16 @@ export async function mcpList(_options: GlobalOptions): Promise { console.log(` secrets: ${needed.join(', ')} (${state})`); } - const installedIn = targets - .filter((t) => (manifest[managedMcpManifestKey(t.tool, t.projectScope)] ?? []).some((r) => r.name === s.name)) - .map((t) => t.tool); + const installed = (t: McpTarget): boolean => + (manifest[managedMcpManifestKey(t.tool, t.projectScope)] ?? []).some((r) => r.name === s.name); + const installedIn = targets.filter(installed).map((t) => t.tool); console.log(` installed: ${installedIn.length > 0 ? installedIn.join(', ') : '(none)'}`); + // Pull writes a resolved value only into a file git leaves out of a commit (#882). + for (const t of targets) { + if (installed(t) || !carriesResolvedValue(t, [s], [s.name])) continue; + const exclusion = await ensureExcludedFromGit(t.file, { dryRun: true }); + if (exclusion.kind === 'failed') console.log(` withheld: ${t.tool} — ${exclusion.reason}. ${exclusion.fix}`); + } console.log(''); } From a9f083621d5b8a3cee044060b6fc837e83a06d84 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 03:14:02 +0200 Subject: [PATCH 11/85] fix(mcp): report a tracked file on a dry run and a withheld server already installed (#882) Backports #880's merge 0d9f7fa7: a dry run (doctor, mcp list) names a tracked file before any pull has listed it, mcp list reports withheld for a server an earlier pull installed, and doctor's withheld note carries the exclusion's own fix instead of the pull --force advice. --- src/__tests__/doctor-mcp-delivery.test.ts | 13 +++++++++ src/__tests__/mcp-cmd.test.ts | 26 ++++++++++++++++++ src/__tests__/mcp-git-exclude.test.ts | 20 +++++++++++++- src/doctor-delivery.ts | 33 ++++++++++++++--------- src/mcp-cmd.ts | 5 ++-- src/mcp-git-exclude.ts | 33 ++++++++++++++++------- 6 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 4d2037900..62dc86427 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -366,5 +366,18 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await excludeCheck()).toBeUndefined(); }); + + it('fails the delivery check for a server withheld from a file git tracks, naming the file and the fix once', async () => { + vi.stubEnv('JIRA_TOKEN', 'fixture-jira-token'); + execFileSync('git', ['add', '.mcp.json'], { cwd: projectRoot }); + + const check = await mcpCheck(); + expect(await check.check()).toBe(false); + const file = path.join(projectRoot, '.mcp.json'); + expect(check.fix).toContain(`In ${file}, withheld: jira, as git would commit the file: git already tracks ${file}.`); + expect(check.fix).toContain(`git rm --cached ${file}\` (rotate any value a commit of it holds)`); + expect(check.fix).not.toContain('not the team\'s definition'); + expect(check.fix).not.toContain('pull --force'); + }); }); }); diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index 9b2a8f04c..e72a381a7 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -29,6 +29,8 @@ import { resolveEntriesFor } from '../namespaced-entries.js'; import { mcpInject, mcpList } from '../mcp-cmd.js'; import { reconcileMcpForConfig, resolveMcpTargets } from '../mcp-reconcile.js'; import { ensureExcludedFromGit } from '../mcp-git-exclude.js'; +import { readJson } from '../utils/fs.js'; +import { managedMcpManifestKey } from '../types.js'; const mockedAutoDetectInit = autoDetectInit as Mock; const mockedResolve = resolveEntriesFor as Mock; @@ -111,6 +113,30 @@ describe('mcpList', () => { expect(text).toContain('withheld: claude — /work/app/.git/info/exclude is not writable. Make it writable, then run `teamai pull` again.'); }); + it('still says a server is withheld from a file an earlier pull installed it in (#882)', async () => { + mockedAutoDetectInit.mockResolvedValue({ + localConfig: { repo: { localPath: '/repo' }, scope: 'project', projectRoot: '/work/app', additionalRoles: [] }, + teamConfig: { toolPaths: {} }, + }); + mockedResolve.mockResolvedValue(resolved([ + [{ name: 'jira', transport: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer ${JIRA_TOKEN}' } }, 'mcp/mcp.yaml', null], + ])); + (resolveMcpTargets as Mock).mockResolvedValueOnce([ + { tool: 'claude', format: 'claude', file: '/work/app/.mcp.json', projectScope: true }, + ]); + (readJson as Mock).mockResolvedValueOnce({ [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }] }); + (ensureExcludedFromGit as Mock).mockResolvedValueOnce({ + kind: 'failed', + reason: 'git already tracks /work/app/.mcp.json', + fix: 'Run `git rm --cached /work/app/.mcp.json` (rotate any value a commit of it holds), then `teamai pull` again.', + }); + + const text = await listOutput(); + + expect(text).toContain('installed: claude'); + expect(text).toContain('withheld: claude — git already tracks /work/app/.mcp.json. Run `git rm --cached /work/app/.mcp.json`'); + }); + it('reports a set that cannot be resolved instead of listing part of it', async () => { mockedResolve.mockResolvedValue({ kind: 'failed', diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index 94d3d32e4..bb858ef7f 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -35,7 +35,7 @@ vi.mock('../utils/fs.js', async (importOriginal) => { }; }); -import { MCP_EXCLUDE_END, MCP_EXCLUDE_START, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; +import { MCP_EXCLUDE_END, MCP_EXCLUDE_START, ensureExcludedFromGit, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; import { acquireLock, releaseLock } from '../update.js'; import { log } from '../utils/logger.js'; @@ -128,6 +128,24 @@ describe('teamai block in .git/info/exclude (#882)', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe('mine/\n'); }); + describe('for a file git already tracks', () => { + beforeEach(async () => { + await fse.writeJson(path.join(repo, '.mcp.json'), {}); + execFileSync('git', ['add', '.mcp.json'], { cwd: repo }); + }); + + it('says so on a dry run before any pull has listed it, and writes nothing', async () => { + const file = path.join(repo, '.mcp.json'); + + expect(await ensureExcludedFromGit(file, { dryRun: true })).toEqual({ + kind: 'failed', + reason: `git already tracks ${file}`, + fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }); + expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); + }); + }); + it('stays quiet outside any repository', async () => { const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); await fse.writeJson(path.join(outside, '.mcp.json'), {}); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 942cbfa13..3e87c32fc 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -465,6 +465,8 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise `${change.server} (${change.reason ?? 'skipped'})`); const problems: string[] = []; + // Its fix is the exclusion's own, not another pull (#882). + let withheld: string | undefined; const installed = await installedMcpEntries(target); if (installed === null) { problems.push(`${target.file} could not be parsed, so no server was injected`); @@ -478,27 +480,32 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise 0) problems.push(`not injected: ${nameList(absent)}`); - if (foreign.length > 0) problems.push(`not the team's definition: ${nameList(foreign)}`); - // Pull writes a resolved value only into a file git leaves out of a commit (#882). - if (carriesResolvedValue(target, teamDefs, [...absent, ...foreign])) { - const exclusion = await ensureExcludedFromGit(target.file, { dryRun: true }); - if (exclusion.kind === 'failed') problems.push(`withheld, as git would commit the file: ${exclusion.reason}. ${exclusion.fix.replace(/\.$/, '')}`); + // Pull writes a resolved value only into a file git leaves out of a + // commit (#882), and otherwise leaves the whole file as it was. + const exclusion = carriesResolvedValue(target, teamDefs, [...absent, ...foreign]) + ? await ensureExcludedFromGit(target.file, { dryRun: true }) + : undefined; + if (exclusion?.kind === 'failed') { + withheld = `In ${target.file}, withheld: ${nameList([...absent, ...foreign])}, as git would commit the file: ${exclusion.reason}. ${exclusion.fix}`; + } else { + if (absent.length > 0) problems.push(`not injected: ${nameList(absent)}`); + if (foreign.length > 0) problems.push(`not the team's definition: ${nameList(foreign)}`); } } if (blocked.length > 0) problems.push(`skipped: ${nameList(blocked)}`); - if (problems.length === 0 && desired.size === 0) continue; + if (problems.length === 0 && !withheld && desired.size === 0) continue; + const delivery = problems.length === 0 ? [] : [`In ${target.file}, ${problems.join('; ')}. A server needing a variable reads it from ` + + '`env/env.yaml` or an active `env//env.yaml`, whose top-level key is `variables:` — a plain `KEY: value` mapping ' + + 'parses as no variables at all. Then run `teamai pull --force`: a pull leaves an entry ' + + 'teamai does not own untouched, so a server of your own under a team name only gives ' + + 'way to `--force`.']; checks.push({ name: `MCP servers delivered to ${target.tool}`, source: 'local', - check: async () => problems.length === 0, - fix: `In ${target.file}, ${problems.join('; ')}. A server needing a variable reads it from ` - + '`env/env.yaml` or an active `env//env.yaml`, whose top-level key is `variables:` — a plain `KEY: value` mapping ' - + 'parses as no variables at all. Then run `teamai pull --force`: a pull leaves an entry ' - + 'teamai does not own untouched, so a server of your own under a team name only gives ' - + 'way to `--force`.', + check: async () => problems.length === 0 && !withheld, + fix: [...withheld ? [withheld] : [], ...delivery].join(' '), }); } diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index 529354c51..d4fddba55 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -73,9 +73,10 @@ export async function mcpList(_options: GlobalOptions): Promise { (manifest[managedMcpManifestKey(t.tool, t.projectScope)] ?? []).some((r) => r.name === s.name); const installedIn = targets.filter(installed).map((t) => t.tool); console.log(` installed: ${installedIn.length > 0 ? installedIn.join(', ') : '(none)'}`); - // Pull writes a resolved value only into a file git leaves out of a commit (#882). + // Pull writes a resolved value only into a file git leaves out of a commit + // (#882); an entry an earlier pull wrote there stays as it was. for (const t of targets) { - if (installed(t) || !carriesResolvedValue(t, [s], [s.name])) continue; + if (!carriesResolvedValue(t, [s], [s.name])) continue; const exclusion = await ensureExcludedFromGit(t.file, { dryRun: true }); if (exclusion.kind === 'failed') console.log(` withheld: ${t.tool} — ${exclusion.reason}. ${exclusion.fix}`); } diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 2ba784092..5ce56572d 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -106,6 +106,20 @@ export async function gitTracking(file: string): Promise { } } +/** + * Whether git tracks `file` (#879): the next `git commit -a` commits a change to + * it, and no exclude rule stops that. Read-only. A file outside any repository + * is not tracked; nor is one in a repository git cannot answer for, where a + * commit fails too. + */ +async function gitTracks(file: string): Promise { + // The file, or even its directory, may be gone from disk and still be in the index. + const dir = await existingAncestor(file); + const result = await execCommand('git', ['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) + .catch(() => null); + return result?.code === 0; +} + /** * teamai's block and what surrounds it; null without both markers, so a damaged * block never takes the member's lines with it. The last start marker opens it: @@ -171,10 +185,17 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo const sep = head === '' || head.endsWith('\n') ? '' : '\n'; return `${head}${sep}${body}\n${block?.after ?? ''}`; }; + // An exclude rule does not apply to a file git tracks already. + const tracked: GitExclusion = { + kind: 'failed', + reason: `git already tracks ${file}`, + fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }; let result: ExcludeUpdate; try { if (options.dryRun) { - if (add((await readFileSafe(excludeFile)) ?? '') !== null) return { kind: 'pending' }; + // Nothing listed yet: only a tracked file would still stop the write. + if (add((await readFileSafe(excludeFile)) ?? '') !== null) return await gitTracks(file) ? tracked : { kind: 'pending' }; result = 'unchanged'; } else { result = await updateExclude(excludeFile, add); @@ -190,15 +211,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo }; } if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); - // An exclude rule does not apply to a file git tracks already. - if ((await gitTracking(file)).kind === 'would-commit') { - return { - kind: 'failed', - reason: 'git already tracks it', - fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, - }; - } - return { kind: 'excluded' }; + return (await gitTracking(file)).kind === 'would-commit' ? tracked : { kind: 'excluded' }; } /** From c4a916057fbaaff1a2d9f5c973e7ce1047cb4f0d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 03:14:51 +0200 Subject: [PATCH 12/85] fix(mcp): name a tracked MCP config before an unwritable .git/info/exclude (#882) A tracked file needs `git rm --cached` whatever else is wrong, so ensureExcludedFromGit checks gitTracks before the writability check, on a pull and a dry run alike, and lists nothing for it. --- src/__tests__/mcp-git-exclude.test.ts | 15 +++++++++++++++ src/mcp-git-exclude.ts | 19 ++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index bb858ef7f..fc8f23e72 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -144,6 +144,21 @@ describe('teamai block in .git/info/exclude (#882)', () => { }); expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); }); + + it.skipIf(process.getuid?.() === 0).each([ + ['a pull', {}], + ['a dry run', { dryRun: true }], + ])('names the tracked file first on %s when .git/info is not writable either', async (_label, options) => { + const info = path.join(repo, '.git', 'info'); + await fse.chmod(info, 0o555); + + try { + const exclusion = await ensureExcludedFromGit(path.join(repo, '.mcp.json'), options); + expect(exclusion).toMatchObject({ kind: 'failed', reason: `git already tracks ${path.join(repo, '.mcp.json')}` }); + } finally { + await fse.chmod(info, 0o755); + } + }); }); it('stays quiet outside any repository', async () => { diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 5ce56572d..0edb5a88d 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -149,7 +149,8 @@ export type GitExclusion = * Add `file` to its repository's `.git/info/exclude` unless git ignores it * already, and whether git now leaves it out of a commit. Idempotent; a path * already ignored, or outside any repository, adds nothing, and one git cannot - * answer for is added all the same. `file` need not exist yet: pull calls this + * answer for is added all the same, and one git tracks fails before anything + * else is checked. `file` need not exist yet: pull calls this * before writing a resolved value into it. `dryRun` writes nothing and reports * what would stop the write. */ @@ -170,6 +171,13 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo // Anchored at the working tree root, glob characters escaped. const rel = path.relative(dir, file).split(path.sep).join('/'); const pattern = `/${location.prefix}${rel}`.replace(/[\\*?[\]!#]/g, '\\$&'); + // An exclude rule does not apply to a file git tracks already: that fix comes first. + const tracked: GitExclusion = { + kind: 'failed', + reason: `git already tracks ${file}`, + fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }; + if (tracking.kind === 'would-commit' && await gitTracks(file)) return tracked; const retry = `Make it writable, or add \`${pattern}\` to it yourself, then run \`teamai pull\` again.`; // A read-only exclude file is the member's choice; the atomic write would replace it all the same. for (const writable of [path.dirname(excludeFile), ...(await pathExists(excludeFile) ? [excludeFile] : [])]) { @@ -185,17 +193,10 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo const sep = head === '' || head.endsWith('\n') ? '' : '\n'; return `${head}${sep}${body}\n${block?.after ?? ''}`; }; - // An exclude rule does not apply to a file git tracks already. - const tracked: GitExclusion = { - kind: 'failed', - reason: `git already tracks ${file}`, - fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, - }; let result: ExcludeUpdate; try { if (options.dryRun) { - // Nothing listed yet: only a tracked file would still stop the write. - if (add((await readFileSafe(excludeFile)) ?? '') !== null) return await gitTracks(file) ? tracked : { kind: 'pending' }; + if (add((await readFileSafe(excludeFile)) ?? '') !== null) return { kind: 'pending' }; result = 'unchanged'; } else { result = await updateExclude(excludeFile, add); From 683a36a4a69dcd2b901b8054bb9a586cd62f45b5 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 03:20:58 +0200 Subject: [PATCH 13/85] fix(mcp): take a project MCP config's exclude line back out once it holds no resolved value (#882) A pull that lists a config in .git/info/exclude and then writes no value into it (it does not parse, a member's server holds the team's name, the write fails) removes the line it added. After a pull or `teamai mcp remove`, a line whose configs are proven clean in every worktree, by the proof uninstall uses (moved to mcp-reconcile.ts), is removed under the lock; one not proven clean stays. A config listed before its write is listed again after it, so a concurrent uninstall that dropped the line between the check and the write does not leave the value unprotected. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-cmd.test.ts | 28 ++++- src/__tests__/mcp-reconcile.test.ts | 103 ++++++++++++++++- src/mcp-cmd.ts | 3 + src/mcp-git-exclude.ts | 12 +- src/mcp-reconcile.ts | 173 ++++++++++++++++++++++++++-- src/uninstall.ts | 90 +-------------- 8 files changed, 307 insertions(+), 106 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 989c2b4b5..63db99f2a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and `teamai uninstall` removes a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) still present. Otherwise, or for a file it cannot check (for example one that does not parse), it keeps the path and warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) still present. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 cef8cce91..e029bed88 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,`teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在 teamai 的写入记录(`managed-mcp.json`)仍在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。否则,或对无法检查的文件(例如无法解析),会保留该路径并给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在 teamai 的写入记录(`managed-mcp.json`)仍在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index e72a381a7..a41a94173 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -10,6 +10,7 @@ vi.mock('../namespaced-entries.js', async (importOriginal) => ({ })); vi.mock('../mcp-reconcile.js', () => ({ reconcileMcpForConfig: vi.fn(), + releaseCleanMcpGitExcludes: vi.fn(), resolveMcpTargets: vi.fn().mockResolvedValue([]), buildVarTable: vi.fn().mockResolvedValue({}), })); @@ -26,8 +27,8 @@ vi.mock('../utils/logger.js', () => ({ import { autoDetectInit } from '../config.js'; import { resolveEntriesFor } from '../namespaced-entries.js'; -import { mcpInject, mcpList } from '../mcp-cmd.js'; -import { reconcileMcpForConfig, resolveMcpTargets } from '../mcp-reconcile.js'; +import { mcpInject, mcpList, mcpRemove } from '../mcp-cmd.js'; +import { reconcileMcpForConfig, releaseCleanMcpGitExcludes, resolveMcpTargets } from '../mcp-reconcile.js'; import { ensureExcludedFromGit } from '../mcp-git-exclude.js'; import { readJson } from '../utils/fs.js'; import { managedMcpManifestKey } from '../types.js'; @@ -173,3 +174,26 @@ describe('mcpInject', () => { } }); }); + +describe('mcpRemove', () => { + it('takes out the .git/info/exclude lines of the configs it leaves clean, after removing the servers (#882)', async () => { + const init = { localConfig: { repo: { localPath: '/repo' }, scope: 'project', projectRoot: '/work/app' }, teamConfig: { toolPaths: {} } }; + mockedAutoDetectInit.mockResolvedValue(init); + const order: string[] = []; + (reconcileMcpForConfig as Mock).mockImplementationOnce(async () => { + order.push('reconcile'); + return { changes: [], wrote: false }; + }); + (releaseCleanMcpGitExcludes as Mock).mockImplementationOnce(async () => { order.push('release'); }); + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await mcpRemove({}); + } finally { + spy.mockRestore(); + } + + expect(reconcileMcpForConfig).toHaveBeenCalledWith(init.teamConfig, init.localConfig, { removeAll: true }); + expect(releaseCleanMcpGitExcludes).toHaveBeenCalledWith(init.teamConfig, init.localConfig); + expect(order).toEqual(['reconcile', 'release']); + }); +}); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index d01a23f89..f806231eb 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -23,12 +23,15 @@ vi.mock('../utils/logger.js', () => ({ // What .git/info/exclude held at the moment each JSON config was written (#882). const excludeAtWrite = vi.hoisted(() => new Map()); +// Runs just before each JSON config write, as a concurrent command would. +const beforeJsonWrite = vi.hoisted(() => ({ run: null as null | ((file: string) => Promise) })); vi.mock('../utils/fs.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, writeJsonAtomic: async (...args: Parameters) => { const [file] = args; + await beforeJsonWrite.run?.(String(file)); const gitDir = path.join(path.dirname(String(file)), '.git'); if (await fse.pathExists(gitDir)) { excludeAtWrite.set(String(file), await actual.readFileSafe(path.join(gitDir, 'info', 'exclude'))); @@ -38,7 +41,7 @@ vi.mock('../utils/fs.js', async (importOriginal) => { }; }); -import { reconcileMcpForConfig, resolveMcpTargets, spliceCodexBlock, codexServerNames } from '../mcp-reconcile.js'; +import { reconcileMcpForConfig, releaseCleanMcpGitExcludes, resolveMcpTargets, spliceCodexBlock, codexServerNames } from '../mcp-reconcile.js'; import { acquireLock, releaseLock } from '../update.js'; import { log } from '../utils/logger.js'; import { resetWarnOnce } from '../utils/warn-once.js'; @@ -1042,6 +1045,104 @@ servers: expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); }); + describe('the line of a config left without a resolved value goes', () => { + const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); + const claudeOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + const open = 'servers:\n - name: open\n transport: http\n url: https://example.com/open\n'; + + afterEach(() => { + beforeJsonWrite.run = null; + }); + + it.each([ + ['it does not parse', () => fse.writeFile(mcpJson(), '{ "mcpServers": ')], + ['it holds a server of the member\'s own under the team\'s name', () => fse.writeJson(mcpJson(), { + mcpServers: { 'with-secret': { type: 'http', url: 'https://mine.example/mcp' } }, + })], + ])('when this pull listed it and then wrote nothing, as %s', async (_label, arrange) => { + await arrange(); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).not.toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + + it('but one an earlier pull listed stays while the config cannot be proven clean', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(managedMcpManifestPath(getDataHome(projectConfig), projectRoot)); + await fse.writeFile(mcpJson(), '{ "mcpServers": '); + vi.stubEnv('SECRET_TOKEN', 'rotated-secret-value'); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('when the last server with a resolved value leaves mcp.yaml', async () => { + await writeMcpYaml(`${withSecret} - name: open\n transport: http\n url: https://example.com/open\n`); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), 'mine/\n'); + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('https://example.com/open'); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + expect(await excludeOf(projectRoot)).toMatch(/^mine\/$/m); + }); + + it('when `teamai mcp remove` takes teamai\'s servers out', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + await reconcileMcpForConfig(teamConfig, claudeOnly(), { removeAll: true }); + await releaseCleanMcpGitExcludes(teamConfig, claudeOnly()); + + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + + it('but not while another worktree\'s copy of the config still holds one', async () => { + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = path.join(tmpDir, 'business-wt'); + git(projectRoot, 'worktree', 'add', '-q', worktree); + await fse.ensureDir(path.join(worktree, '.claude', 'skills')); + const { resolveProjectDataHome } = await import('../config.js'); + const other = { ...claudeOnly(), projectRoot: worktree, dataHome: await resolveProjectDataHome(worktree) } as LocalConfig; + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(teamConfig, other); + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(path.join(worktree, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + }); + + it('lists the config again when a concurrent uninstall drops its line between the check and the write', async () => { + const { findMcpGitExcludes, removeMcpGitExclude } = await import('../mcp-git-exclude.js'); + beforeJsonWrite.run = async () => { + for (const [excludeFile, entries] of await findMcpGitExcludes([projectRoot])) { + await removeMcpGitExclude(excludeFile, entries.map((entry) => entry.pattern)); + } + }; + await writeMcpYaml(withSecret); + + try { + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + } finally { + beforeJsonWrite.run = null; + } + + expect(excludeAtWrite.get(path.join(projectRoot, '.mcp.json'))).not.toContain('teamai'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + describe('when the config cannot be kept out of git first', () => { const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); const infoDir = (): string => path.join(projectRoot, '.git', 'info'); diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index d4fddba55..0fdbde58e 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -4,6 +4,7 @@ import { mcpEntryReader, teamMcpToDef } from './resources/mcp.js'; import { describeEntryFailure, describeOrigin, resolveEntriesFor } from './namespaced-entries.js'; import { reconcileMcpForConfig, + releaseCleanMcpGitExcludes, resolveMcpTargets, buildVarTable, type McpChange, @@ -125,6 +126,8 @@ export async function mcpInject( export async function mcpRemove(_options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); const { changes, wrote } = await reconcileMcpForConfig(teamConfig, localConfig, { removeAll: true }); + // Nothing of teamai's is left for .git/info/exclude to protect (#882). + await releaseCleanMcpGitExcludes(teamConfig, localConfig); console.log('MCP remove:'); reportChanges(changes); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 0edb5a88d..a80043de6 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -76,7 +76,7 @@ async function gitExcludeFile(dir: string): Promise<{ excludeFile: string; root: } /** The closest directory above `file` that exists. */ -async function existingAncestor(file: string): Promise { +export async function existingAncestor(file: string): Promise { let dir = path.dirname(path.resolve(file)); while (!await pathExists(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir); return dir; @@ -137,11 +137,11 @@ function splitBlock(content: string): { before: string; patterns: string[]; afte /** * Whether `file` is kept out of git, or why teamai could not keep it out and - * what the member does about it. `pending`: a dry run found nothing in the way - * of listing it. + * what the member does about it. `added`: this call listed it. `pending`: a dry + * run found nothing in the way of listing it. */ export type GitExclusion = - | { kind: 'excluded' } + | { kind: 'excluded'; added: boolean } | { kind: 'pending' } | { kind: 'failed'; reason: string; fix: string }; @@ -156,7 +156,7 @@ export type GitExclusion = */ export async function ensureExcludedFromGit(file: string, options: { dryRun?: boolean } = {}): Promise { const tracking = await gitTracking(file); - if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return { kind: 'excluded' }; + if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return { kind: 'excluded', added: false }; // `file` and its directory need not exist yet: git is asked from the nearest one that does. const dir = await existingAncestor(file); const location = await gitExcludeFile(dir); @@ -212,7 +212,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo }; } if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); - return (await gitTracking(file)).kind === 'would-commit' ? tracked : { kind: 'excluded' }; + return (await gitTracking(file)).kind === 'would-commit' ? tracked : { kind: 'excluded', added: result === 'written' }; } /** diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 99c5f1c6a..6a4a23680 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -45,7 +45,17 @@ import { import { log } from './utils/logger.js'; import { loadProjectMcpManifest } from './utils/mcp-manifest.js'; import { isOnPath, SAFE_BIN_RE, type LookPathOptions } from './utils/lookpath.js'; -import { carriesResolvedValue, ensureExcludedFromGit, excludeFromGit, resolvedVariableIn, type GitExclusion } from './mcp-git-exclude.js'; +import { + carriesResolvedValue, + ensureExcludedFromGit, + excludeFromGit, + existingAncestor, + findMcpGitExcludes, + removeMcpGitExclude, + resolvedVariableIn, + type GitExclusion, +} from './mcp-git-exclude.js'; +import { listWorktrees } from './utils/git.js'; // ─── Reconcile engine ──────────────────────────────────────── // @@ -530,6 +540,83 @@ export async function resolvedValueEvidence( return variable ? `the value of $${variable}` : null; } +/** + * `localConfig` and, in project scope, one config per other linked worktree: + * each worktree has its own MCP configs and managed-mcp manifest. + */ +export async function projectWorktreeConfigs(localConfig: LocalConfig): Promise { + const configs: LocalConfig[] = [localConfig]; + if (localConfig.scope === 'project' && localConfig.projectRoot) { + const { resolveProjectDataHome } = await import('./config.js'); + for (const wt of await listWorktrees(localConfig.projectRoot)) { + if (wt === localConfig.projectRoot) continue; + configs.push({ ...localConfig, projectRoot: wt, dataHome: await resolveProjectDataHome(wt) }); + } + } + return configs; +} + +/** + * The `files` not proven free of a value teamai resolved (#882), each with why. + * A missing file is clean; so is one a tool reads that parses and holds no + * server at all. One holding servers is clean only when its worktree's manifest + * is there to say what teamai wrote, and the file holds none of the team's + * servers that need a resolved `${VAR}` there, none of teamai's own entries the + * manifest records and cleanup left (their definition may have left mcp.yaml), + * and none of the values of the variables set in this environment. Anything + * else (no tool reads it, it does not parse, the team's servers cannot be read, + * the manifest is lost) is not: a server teamai wrote, since dropped from + * mcp.yaml, with a value no longer set, looks like the member's own. + */ +export async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: LocalConfig, files: string[]): Promise> { + const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); + const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); + // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). + const targets = new Map(); + for (const cfg of await projectWorktreeConfigs(localConfig)) { + let manifest: ManagedMcpManifest = {}; + let recorded = false; + if (cfg.projectRoot) { + const loaded = await loadProjectMcpManifest(getDataHome(cfg), cfg.projectRoot, { dryRun: true }); + manifest = loaded.manifest; + recorded = Object.keys(manifest).length > 0 || await pathExists(loaded.manifestPath); + } + for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { + const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); + const key = path.join(dir, path.basename(target.file)); + const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); + // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. + const seen = targets.get(key); + targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], recorded: recorded || seen?.recorded === true }); + } + } + // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. + const identity = new Set(['USER', 'LOGNAME', 'USERNAME']); + const vars = await buildVarTable(localConfig); + const values = Object.entries(vars) + .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)); + const held = new Map(); + for (const file of files) { + if (!await pathExists(file)) continue; + const known = targets.get(file); + const installed = known ? await installedMcpEntries(known.target) : null; + const raw = (await readFileSafe(file)) ?? ''; + const named = known && installed && teamDefs + ? [...installed.keys()].find((name) => carriesResolvedValue(known.target, teamDefs, [name])) + : undefined; + const why = !known ? 'no tool teamai knows reads it' + : !installed ? 'it does not parse' + : installed.size === 0 ? undefined + : !teamDefs ? 'the team\'s MCP servers cannot be read' + : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` + : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) + ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] + ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone'); + if (why) held.set(file, why); + } + return held; +} + // ─── Main entry ────────────────────────────────────────────── export function mcpTargetExcluded(localConfig: LocalConfig, target: McpTarget): boolean { @@ -560,9 +647,10 @@ export async function reconcileMcpForConfig( /** * List each project MCP config holding a value teamai resolved in - * `.git/info/exclude` (#882). It covers what is on disk, whether or not this - * run delivered to it: the file of a disabled or undetected tool, or one - * written before the team turned delivery off, still holds what a pull wrote. + * `.git/info/exclude` (#882), and take out the line of one proven clean. It + * covers what is on disk, whether or not this run delivered to it: the file of + * a disabled or undetected tool, or one written before the team turned + * delivery off, still holds what a pull wrote. */ async function protectResolvedMcpConfigs( teamConfig: TeamaiConfig, @@ -591,12 +679,83 @@ async function protectProjectMcpConfigs( const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const vars = await buildVarTable(localConfig); + const holding = new Set(); + const unproven = new Set(); for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { - // Tried before its write this run, and reported there when it failed. - if (exclusions.has(target.file)) continue; + // Tried before its write this run, and reported there. + if (exclusions.get(target.file)?.kind === 'failed') continue; const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - if (await resolvedValueEvidence(target, teamDefs, owned, vars)) await excludeFromGit(target.file); + if (await resolvedValueEvidence(target, teamDefs, owned, vars)) holding.add(target.file); + else unproven.add(target.file); } + // Also a file listed before its write: a concurrent uninstall may have taken its line out since. + for (const file of holding) await excludeFromGit(file); + // A line this run added for a file it then wrote no value into restores the file's state before the run. + const addedNow = [...unproven].filter((file) => { + const exclusion = exclusions.get(file); + return !holding.has(file) && exclusion?.kind === 'excluded' && exclusion.added; + }); + await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow); +} + +/** + * Take out of teamai's block in `.git/info/exclude` the line of each project + * MCP config proven free of a value teamai resolved (#882), in every worktree + * sharing it: `teamai mcp remove` leaves nothing of teamai's to protect. A + * config not proven clean keeps its line. + */ +export async function releaseCleanMcpGitExcludes(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const { projectRoot } = localConfig; + if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; + try { + await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, []); + } catch (e) { + log.warn( + `Could not check whether this project's MCP configs still need their .git/info/exclude lines: ${e instanceof Error ? e.message : String(e)}. ` + + 'The lines stay; `teamai uninstall` removes them.', + ); + } +} + +/** + * Remove each line of teamai's block whose files are all proven clean or in + * `addedNow`: files this run listed and holds no evidence for, whose line it + * takes back out even when they cannot be proven clean (one that does not parse). + */ +async function releaseMcpGitExcludes( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + projectRoot: string, + addedNow: string[], +): Promise { + const dirs = [projectRoot]; + for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) dirs.push(path.dirname(target.file)); + const excludes = await findMcpGitExcludes(dirs); + if (excludes.size === 0) return; + // Keyed as findMcpGitExcludes keys them: by real path (macOS /var). + const exempt = new Set(await Promise.all(addedNow.map(realFilePath))); + const held = await mcpConfigsNotProvenClean( + teamConfig, + localConfig, + [...excludes.values()].flatMap((entries) => entries.flatMap((entry) => entry.files)), + ); + for (const [excludeFile, entries] of excludes) { + const clean = entries + .filter((entry) => entry.files.every((file) => !held.has(file) || exempt.has(file))) + .map((entry) => entry.pattern); + if (clean.length === 0) continue; + const result = await removeMcpGitExclude(excludeFile, clean); + if (result === 'written') log.info(`Removed ${clean.join(', ')} from ${excludeFile}: no MCP config there holds a value teamai resolved.`); + // Left as it is: the next pull tries again. + if (result === 'locked') log.debug(`Kept ${clean.join(', ')} in ${excludeFile}: another teamai command held it past the wait.`); + } +} + +/** `file` with the real path of its closest existing directory. */ +async function realFilePath(file: string): Promise { + const dir = await existingAncestor(file); + const real = await fse.realpath(dir).catch(() => dir); + return path.join(real, path.relative(dir, file)); } async function reconcileTargets( diff --git a/src/uninstall.ts b/src/uninstall.ts index 889f12462..fc509bd59 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,5 +1,4 @@ import path from 'node:path'; -import fs from 'node:fs/promises'; import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope } from './config.js'; import { reconcileHooks, hasTeamaiHooks } from './hooks.js'; import { @@ -20,7 +19,6 @@ import { TEAMAI_ENV_START, TEAMAI_ENV_END, getDataHome, - managedMcpManifestKey, getManagedHooksPath, isAgentExcluded, managedMcpManifestPath, @@ -35,7 +33,6 @@ import { type Scope, type ManagedMcpManifest, } from './types.js'; -import type { McpTarget } from './mcp-reconcile.js'; import { BUILTIN_RULE_NAMES } from './builtin-rules.js'; import { ruleStemFromFilename } from './resources/rule-format.js'; import { agentStemFromFilename } from './resources/agent-format.js'; @@ -494,89 +491,6 @@ async function discoverToolResources( return res; } -/** - * `localConfig` and, in project scope, one config per other linked worktree: - * each worktree has its own MCP configs and managed-mcp manifest. - */ -async function projectWorktreeConfigs(localConfig: LocalConfig): Promise { - const configs: LocalConfig[] = [localConfig]; - if (localConfig.scope === 'project' && localConfig.projectRoot) { - const { listWorktrees } = await import('./utils/git.js'); - const { resolveProjectDataHome } = await import('./config.js'); - for (const wt of await listWorktrees(localConfig.projectRoot)) { - if (wt === localConfig.projectRoot) continue; - configs.push({ ...localConfig, projectRoot: wt, dataHome: await resolveProjectDataHome(wt) }); - } - } - return configs; -} - -/** - * The `files` not proven free of a value teamai resolved (#882), each with why. - * A missing file is clean; so is one a tool reads that parses and holds no - * server at all. One holding servers is clean only when its worktree's manifest - * is there to say what teamai wrote, and the file holds none of the team's - * servers that need a resolved `${VAR}` there, none of teamai's own entries the - * manifest records and cleanup left (their definition may have left mcp.yaml), - * and none of the values of the variables set in this environment. Anything - * else (no tool reads it, it does not parse, the team's servers cannot be read, - * the manifest is lost) is not: a server teamai wrote, since dropped from - * mcp.yaml, with a value no longer set, looks like the member's own. - */ -async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: LocalConfig, files: string[]): Promise> { - const { resolveMcpTargets, installedMcpEntries, buildVarTable, resolvedValueEvidence } = await import('./mcp-reconcile.js'); - const { carriesResolvedValue } = await import('./mcp-git-exclude.js'); - const { mcpEntryReader, teamMcpToDef } = await import('./resources/mcp.js'); - const { resolveEntriesFor } = await import('./namespaced-entries.js'); - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); - const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); - // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); - for (const cfg of await projectWorktreeConfigs(localConfig)) { - let manifest: ManagedMcpManifest = {}; - let recorded = false; - if (cfg.projectRoot) { - const loaded = await loadProjectMcpManifest(getDataHome(cfg), cfg.projectRoot, { dryRun: true }); - manifest = loaded.manifest; - recorded = Object.keys(manifest).length > 0 || await pathExists(loaded.manifestPath); - } - for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { - const dir = await fs.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); - const key = path.join(dir, path.basename(target.file)); - const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. - const seen = targets.get(key); - targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], recorded: recorded || seen?.recorded === true }); - } - } - // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. - const identity = new Set(['USER', 'LOGNAME', 'USERNAME']); - const vars = await buildVarTable(localConfig); - const values = Object.entries(vars) - .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)); - const held = new Map(); - for (const file of files) { - if (!await pathExists(file)) continue; - const known = targets.get(file); - const installed = known ? await installedMcpEntries(known.target) : null; - const raw = (await readFileSafe(file)) ?? ''; - const named = known && installed && teamDefs - ? [...installed.keys()].find((name) => carriesResolvedValue(known.target, teamDefs, [name])) - : undefined; - const why = !known ? 'no tool teamai knows reads it' - : !installed ? 'it does not parse' - : installed.size === 0 ? undefined - : !teamDefs ? 'the team\'s MCP servers cannot be read' - : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` - : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) - ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] - ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone'); - if (why) held.set(file, why); - } - return held; -} - async function buildRemovalPlan( localConfig: LocalConfig, teamConfig: TeamaiConfig, @@ -808,7 +722,7 @@ async function buildRemovalPlan( // that of any nested repository an MCP config sits in. It counts on its // own: a clone whose other resources are gone still gets it removed. if (localConfig.scope === 'project') { - const { resolveMcpTargets } = await import('./mcp-reconcile.js'); + const { resolveMcpTargets, projectWorktreeConfigs } = await import('./mcp-reconcile.js'); const { findMcpGitExcludes } = await import('./mcp-git-exclude.js'); const dirs: string[] = []; for (const cfg of await projectWorktreeConfigs(localConfig)) { @@ -1315,7 +1229,7 @@ export async function uninstall(opts: UninstallOptions): Promise { // must leave the remaining tools' MCP servers intact. if (plan.includeShared) { try { - const { reconcileMcpForConfig } = await import('./mcp-reconcile.js'); + const { reconcileMcpForConfig, projectWorktreeConfigs, mcpConfigsNotProvenClean } = await import('./mcp-reconcile.js'); // Project scope: the managed-mcp manifests are PER-WORKTREE under the // shared partition (#374 P1-2C), and each worktree's MCP config lives in // its own checkout. Since executeRemoval deletes the whole shared From ae6d4a60b3098781097136978c95b298f1c74633 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 03:43:05 +0200 Subject: [PATCH 14/85] fix(mcp): judge a project MCP config by the manifest as it stood before the pull rewrote it (#882) A pull whose manifest was lost before it ran recreates managed-mcp.json while reconciling, so the clean-file proof read the new record and took the exclude line out of a file still holding a teamai server that left mcp.yaml with its variable unset. The proof now uses this worktree's manifest as read before the reconcile. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-reconcile.test.ts | 15 +++++++++++ src/mcp-reconcile.ts | 42 +++++++++++++++++++++-------- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 63db99f2a..3f13b410e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) still present. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 e029bed88..d268de113 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在 teamai 的写入记录(`managed-mcp.json`)仍在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index f806231eb..71b3ece6c 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1082,6 +1082,21 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('but one an earlier pull listed stays when this pull rewrote the manifest it had lost', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(managedMcpManifestPath(getDataHome(projectConfig), projectRoot)); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await fse.pathExists(managedMcpManifestPath(getDataHome(projectConfig), projectRoot))).toBe(true); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('when the last server with a resolved value leaves mcp.yaml', async () => { await writeMcpYaml(`${withSecret} - name: open\n transport: http\n url: https://example.com/open\n`); await reconcileMcpForConfig(teamConfig, claudeOnly()); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 6a4a23680..d6231b6cf 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -556,6 +556,17 @@ export async function projectWorktreeConfigs(localConfig: LocalConfig): Promise< return configs; } +/** A project worktree's managed-mcp.json, and whether it is there at all: a lost one proves nothing. */ +export interface ProjectMcpRecord { + manifest: ManagedMcpManifest; + recorded: boolean; +} + +async function readProjectMcpRecord(cfg: LocalConfig, projectRoot: string): Promise { + const { manifest, manifestPath } = await loadProjectMcpManifest(getDataHome(cfg), projectRoot, { dryRun: true }); + return { manifest, recorded: Object.keys(manifest).length > 0 || await pathExists(manifestPath) }; +} + /** * The `files` not proven free of a value teamai resolved (#882), each with why. * A missing file is clean; so is one a tool reads that parses and holds no @@ -567,20 +578,22 @@ export async function projectWorktreeConfigs(localConfig: LocalConfig): Promise< * else (no tool reads it, it does not parse, the team's servers cannot be read, * the manifest is lost) is not: a server teamai wrote, since dropped from * mcp.yaml, with a value no longer set, looks like the member's own. + * `before` is `localConfig`'s manifest as it stood before a reconcile rewrote it. */ -export async function mcpConfigsNotProvenClean(teamConfig: TeamaiConfig, localConfig: LocalConfig, files: string[]): Promise> { +export async function mcpConfigsNotProvenClean( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + files: string[], + before?: ProjectMcpRecord, +): Promise> { const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). const targets = new Map(); for (const cfg of await projectWorktreeConfigs(localConfig)) { - let manifest: ManagedMcpManifest = {}; - let recorded = false; - if (cfg.projectRoot) { - const loaded = await loadProjectMcpManifest(getDataHome(cfg), cfg.projectRoot, { dryRun: true }); - manifest = loaded.manifest; - recorded = Object.keys(manifest).length > 0 || await pathExists(loaded.manifestPath); - } + const { manifest, recorded } = cfg === localConfig && before ? before + : cfg.projectRoot ? await readProjectMcpRecord(cfg, cfg.projectRoot) + : { manifest: {}, recorded: false }; for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); @@ -637,11 +650,14 @@ export async function reconcileMcpForConfig( ): Promise { // Each project config's exclusion from git, established before a resolved value is written into it. const exclusions = new Map(); + const protect = !options.removeAll && !options.dryRun; + // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. + const before = protect && localConfig.projectRoot ? await readProjectMcpRecord(localConfig, localConfig.projectRoot) : undefined; try { return await reconcileTargets(teamConfig, localConfig, options, exclusions); } finally { // Also after a failed write: what earlier pulls wrote is on disk either way. - if (!options.removeAll && !options.dryRun) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions); + if (protect) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions, before); } } @@ -656,11 +672,12 @@ async function protectResolvedMcpConfigs( teamConfig: TeamaiConfig, localConfig: LocalConfig, exclusions: Map, + before: ProjectMcpRecord | undefined, ): Promise { const { projectRoot } = localConfig; if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; try { - await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions); + await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions, before); } catch (e) { log.warn( `Could not check this project's MCP configs for resolved values to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` @@ -674,6 +691,7 @@ async function protectProjectMcpConfigs( localConfig: LocalConfig, projectRoot: string, exclusions: Map, + before: ProjectMcpRecord | undefined, ): Promise { const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); @@ -695,7 +713,7 @@ async function protectProjectMcpConfigs( const exclusion = exclusions.get(file); return !holding.has(file) && exclusion?.kind === 'excluded' && exclusion.added; }); - await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow); + await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before); } /** @@ -727,6 +745,7 @@ async function releaseMcpGitExcludes( localConfig: LocalConfig, projectRoot: string, addedNow: string[], + before?: ProjectMcpRecord, ): Promise { const dirs = [projectRoot]; for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) dirs.push(path.dirname(target.file)); @@ -738,6 +757,7 @@ async function releaseMcpGitExcludes( teamConfig, localConfig, [...excludes.values()].flatMap((entries) => entries.flatMap((entry) => entry.files)), + before, ); for (const [excludeFile, entries] of excludes) { const clean = entries From 389635e594705960d0c0b3da953c0ad666ceaf86 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 03:43:12 +0200 Subject: [PATCH 15/85] fix(mcp): log a rolled-back exclude line at debug level (#882) A line this pull added and took back out, because it wrote no resolved value into the file, was reported as removed although the member never saw it added. Only removing a line an earlier run added stays at info. --- src/__tests__/mcp-reconcile.test.ts | 9 +++++++++ src/mcp-reconcile.ts | 13 +++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 71b3ece6c..0f739fdd5 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1050,6 +1050,11 @@ servers: const claudeOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); const open = 'servers:\n - name: open\n transport: http\n url: https://example.com/open\n'; + beforeEach(() => { + vi.mocked(log.info).mockClear(); + vi.mocked(log.debug).mockClear(); + }); + afterEach(() => { beforeJsonWrite.run = null; }); @@ -1067,6 +1072,9 @@ servers: expect(await fse.readFile(mcpJson(), 'utf-8')).not.toContain('super-secret-value'); expect(await excludeOf(projectRoot)).not.toContain('teamai'); + // The member never saw the line go in, so its rollback is not news. + expect(vi.mocked(log.info).mock.calls.flat().join('\n')).not.toMatch(/Removed/); + expect(vi.mocked(log.debug).mock.calls.flat().join('\n')).toMatch(/\/\.mcp\.json/); }); it('but one an earlier pull listed stays while the config cannot be proven clean', async () => { @@ -1108,6 +1116,7 @@ servers: expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('https://example.com/open'); expect(await excludeOf(projectRoot)).not.toContain('teamai'); expect(await excludeOf(projectRoot)).toMatch(/^mine\/$/m); + expect(log.info).toHaveBeenCalledWith(expect.stringMatching(/^Removed \/\.mcp\.json from /)); }); it('when `teamai mcp remove` takes teamai\'s servers out', async () => { diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index d6231b6cf..62a9dd243 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -760,12 +760,17 @@ async function releaseMcpGitExcludes( before, ); for (const [excludeFile, entries] of excludes) { - const clean = entries - .filter((entry) => entry.files.every((file) => !held.has(file) || exempt.has(file))) - .map((entry) => entry.pattern); + const cleanEntries = entries.filter((entry) => entry.files.every((file) => !held.has(file) || exempt.has(file))); + const clean = cleanEntries.map((entry) => entry.pattern); if (clean.length === 0) continue; const result = await removeMcpGitExclude(excludeFile, clean); - if (result === 'written') log.info(`Removed ${clean.join(', ')} from ${excludeFile}: no MCP config there holds a value teamai resolved.`); + if (result === 'written') { + // A line this run added and took back out is no change the member saw. + const rolledBack = cleanEntries.filter((entry) => entry.files.some((file) => exempt.has(file))).map((entry) => entry.pattern); + const released = clean.filter((pattern) => !rolledBack.includes(pattern)); + if (released.length > 0) log.info(`Removed ${released.join(', ')} from ${excludeFile}: no MCP config there holds a value teamai resolved.`); + if (rolledBack.length > 0) log.debug(`Took ${rolledBack.join(', ')} back out of ${excludeFile}: this run wrote no resolved value there.`); + } // Left as it is: the next pull tries again. if (result === 'locked') log.debug(`Kept ${clean.join(', ')} in ${excludeFile}: another teamai command held it past the wait.`); } From 06f3b8db5a0cdd4db02ed6010d9529d02a7e97de Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 04:21:07 +0200 Subject: [PATCH 16/85] fix(mcp): keep a shared exclude line while another worktree's config holds a server (#882) A pull or `teamai mcp remove` judged every linked worktree's MCP config with today's definitions and values. Once a server's ${VAR} became a literal, and the value was no longer set, a pull in worktree A took worktree B's stale token-bearing entry for clean and removed the shared /.mcp.json line, so `git add -A` in B staged the token. These commands now release a line only when the current worktree's file passes the full proof and every other worktree's file is missing or holds no MCP server. `teamai uninstall` keeps its full proof in each worktree. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-reconcile.test.ts | 37 +++++++++++++++++++++++++++++ src/mcp-reconcile.ts | 24 +++++++++++++++---- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 3f13b410e..604a389d8 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file, in every worktree of its repository, is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 d268de113..aea4ff389 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件(在其仓库的每个 worktree 中)已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 0f739fdd5..e8a8c31a1 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1146,6 +1146,43 @@ servers: expect(await fse.readFile(path.join(worktree, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + + describe('after a server\'s ${VAR} became a literal, judged from another worktree', () => { + const literal = withSecret.replace('${SECRET_TOKEN}', 'published-literal'); + let worktree: string; + + beforeEach(async () => { + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'); + // As git lists it (macOS /var is a symlink), so its manifest is found under the same key. + worktree = path.join(await fse.realpath(tmpDir), 'business-wt'); + git(projectRoot, 'worktree', 'add', '-q', worktree); + await fse.ensureDir(path.join(worktree, '.claude', 'skills')); + const { resolveProjectDataHome } = await import('../config.js'); + const other = { ...claudeOnly(), projectRoot: worktree, dataHome: await resolveProjectDataHome(worktree) } as LocalConfig; + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(teamConfig, other); + await writeMcpYaml(literal); + vi.stubEnv('SECRET_TOKEN', ''); + }); + + it('keeps the shared line while that worktree\'s config still holds the stale entry', async () => { + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('published-literal'); + expect(await fse.readFile(path.join(worktree, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(git(worktree, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.mcp\.json/); + }); + + it('removes the line once that worktree\'s config is gone', async () => { + await fse.remove(path.join(worktree, '.mcp.json')); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + }); }); it('lists the config again when a concurrent uninstall drops its line between the check and the write', async () => { diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 62a9dd243..2c5835cf2 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -579,28 +579,43 @@ async function readProjectMcpRecord(cfg: LocalConfig, projectRoot: string): Prom * the manifest is lost) is not: a server teamai wrote, since dropped from * mcp.yaml, with a value no longer set, looks like the member's own. * `before` is `localConfig`'s manifest as it stood before a reconcile rewrote it. + * With `otherWorktrees: 'empty'` another worktree's file is clean only when it + * holds no server at all: today's definitions and values cannot judge an entry + * that worktree's last pull wrote (a `${VAR}` since made a literal), only a + * pull there can. */ export async function mcpConfigsNotProvenClean( teamConfig: TeamaiConfig, localConfig: LocalConfig, files: string[], - before?: ProjectMcpRecord, + options: { before?: ProjectMcpRecord; otherWorktrees?: 'judged' | 'empty' } = {}, ): Promise> { + const { before, otherWorktrees = 'judged' } = options; const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); + const targets = new Map(); + const realRoot = (root: string | undefined): Promise => + root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); + const ownRoot = await realRoot(localConfig.projectRoot); for (const cfg of await projectWorktreeConfigs(localConfig)) { const { manifest, recorded } = cfg === localConfig && before ? before : cfg.projectRoot ? await readProjectMcpRecord(cfg, cfg.projectRoot) : { manifest: {}, recorded: false }; + // This checkout listed again under its real path is not another worktree. + const foreign = cfg !== localConfig && await realRoot(cfg.projectRoot) !== ownRoot; for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. const seen = targets.get(key); - targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], recorded: recorded || seen?.recorded === true }); + targets.set(key, { + target, + owned: [...seen?.owned ?? [], ...owned], + recorded: recorded || seen?.recorded === true, + foreign: foreign || seen?.foreign === true, + }); } } // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. @@ -620,6 +635,7 @@ export async function mcpConfigsNotProvenClean( const why = !known ? 'no tool teamai knows reads it' : !installed ? 'it does not parse' : installed.size === 0 ? undefined + : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' : !teamDefs ? 'the team\'s MCP servers cannot be read' : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) @@ -757,7 +773,7 @@ async function releaseMcpGitExcludes( teamConfig, localConfig, [...excludes.values()].flatMap((entries) => entries.flatMap((entry) => entry.files)), - before, + { before, otherWorktrees: 'empty' }, ); for (const [excludeFile, entries] of excludes) { const cleanEntries = entries.filter((entry) => entry.files.every((file) => !held.has(file) || exempt.has(file))); From 7810e524f58e0f2e6a51a5056c6e2e37ad5dcf43 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 04:42:26 +0200 Subject: [PATCH 17/85] test(mcp): build the other worktree from the real temp path so the test checks what it names (#882) --- src/__tests__/mcp-reconcile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index e8a8c31a1..40db23a15 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1131,7 +1131,7 @@ servers: it('but not while another worktree\'s copy of the config still holds one', async () => { git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'); - const worktree = path.join(tmpDir, 'business-wt'); + const worktree = path.join(await fse.realpath(tmpDir), 'business-wt'); git(projectRoot, 'worktree', 'add', '-q', worktree); await fse.ensureDir(path.join(worktree, '.claude', 'skills')); const { resolveProjectDataHome } = await import('../config.js'); From e5331ff5b1d387dad8734a4886d182e982f3e3fb Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 07:05:17 +0200 Subject: [PATCH 18/85] fix(uninstall): list no worktrees for a project root that no longer exists (#882) buildRemovalPlan now lists every worktree to find teamai's exclude blocks, outside the MCP cleanup's try. simple-git throws synchronously for a missing directory, so a project uninstall whose root is gone crashed; main's #878 test caught it after the merge. --- src/utils/git.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utils/git.ts b/src/utils/git.ts index c0547d50b..e6579b836 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -869,15 +869,15 @@ async function readAnchors(cwd?: string): Promise { /** * List the realpath'd top-level directory of every worktree of the repo that * contains `cwd` (main checkout + all linked worktrees), from - * `git worktree list --porcelain`. Returns [] outside a git repo. Used by a - * project-wide uninstall to clean each worktree's managed resources before the - * shared partition is deleted (issue #374 P1-2C). + * `git worktree list --porcelain`. Returns [] outside a git repo, or when `cwd` + * does not exist. Used by a project-wide uninstall to clean each worktree's + * managed resources before the shared partition is deleted (issue #374 P1-2C). */ export async function listWorktrees(cwd?: string): Promise { - const git = createGit(cwd); let list: string; try { - list = await git.raw(['worktree', 'list', '--porcelain']); + // Inside the try: simple-git throws at once for a directory that does not exist. + list = await createGit(cwd).raw(['worktree', 'list', '--porcelain']); } catch { return []; } From b0401243dd37d490b1ef06eac285b8bc3a8abbd3 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 07:08:13 +0200 Subject: [PATCH 19/85] fix(mcp): treat an empty, unparsable or tool-less managed-mcp.json as no record (#882) The clean-file proof took any managed-mcp.json on disk as teamai's record, so an empty or truncated one let a pull, `mcp remove` or uninstall judge a file still holding a stale secret-bearing entry clean and drop its exclude line. A file now counts as recorded only when the manifest parses and holds an entry for that tool's file. A project record teamai empties stays as [] so a file left with only the member's own servers can still be released. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/mcp-reconcile.test.ts | 45 ++++++++++++++++++++++++ src/__tests__/uninstall.test.ts | 14 ++++++++ src/mcp-reconcile.ts | 54 ++++++++++++++--------------- 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f15f27210..37f0aee1d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing). `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 074b16963..276d23d57 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在的情况下不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 40db23a15..37174fa41 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1105,6 +1105,51 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + describe.each([ + ['empty', ''], + ['truncated', '{ "claude:project": [ { "name": "with-sec'], + ['recording nothing for this tool', '{ "cursor:project": [ { "name": "with-secret", "hash": "h" } ] }'], + ])('but one an earlier pull listed stays while managed-mcp.json is %s', (_label, content) => { + beforeEach(async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.writeFile(managedMcpManifestPath(getDataHome(projectConfig), projectRoot), content); + vi.stubEnv('SECRET_TOKEN', ''); + }); + + it('and a pull finds its server gone from mcp.yaml', async () => { + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('and `teamai mcp remove` runs after its server left mcp.yaml', async () => { + await writeMcpYaml('servers: []\n'); + + await reconcileMcpForConfig(teamConfig, claudeOnly(), { removeAll: true }); + await releaseCleanMcpGitExcludes(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + }); + + it('when `teamai mcp remove` takes teamai\'s servers out of a config that also holds the member\'s own', async () => { + await fse.writeJson(mcpJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + await reconcileMcpForConfig(teamConfig, claudeOnly(), { removeAll: true }); + await releaseCleanMcpGitExcludes(teamConfig, claudeOnly()); + + expect(await fse.readJson(mcpJson())).toEqual({ mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + it('when the last server with a resolved value leaves mcp.yaml', async () => { await writeMcpYaml(`${withSecret} - name: open\n transport: http\n url: https://example.com/open\n`); await reconcileMcpForConfig(teamConfig, claudeOnly()); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 54e5ea02d..27b4bfd4c 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -931,6 +931,20 @@ describe('uninstall', () => { expect(warning).toContain(await fse.realpath(excludeFile)); }); + it.each([ + ['empty', ''], + ['truncated', '{ "claude:project": [ { "name": "ji'], + ])('keeps the entry when managed-mcp.json is %s, the server left mcp.yaml and its value is not set', async (_label, content) => { + const { repoPath, projectRoot, excludeFile, localConfig } = await setup(); + await fse.outputFile(path.join(repoPath, 'mcp', 'mcp.yaml'), 'servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira } }); + await fse.outputFile(managedMcpManifestPath(getDataHome(localConfig), projectRoot), content); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + }); + it('removes the entry when the file holds no server, with no manifest', async () => { const { projectRoot, excludeFile } = await setup(); await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: {} }); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 2c5835cf2..ec2868071 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -556,28 +556,24 @@ export async function projectWorktreeConfigs(localConfig: LocalConfig): Promise< return configs; } -/** A project worktree's managed-mcp.json, and whether it is there at all: a lost one proves nothing. */ -export interface ProjectMcpRecord { - manifest: ManagedMcpManifest; - recorded: boolean; -} - -async function readProjectMcpRecord(cfg: LocalConfig, projectRoot: string): Promise { - const { manifest, manifestPath } = await loadProjectMcpManifest(getDataHome(cfg), projectRoot, { dryRun: true }); - return { manifest, recorded: Object.keys(manifest).length > 0 || await pathExists(manifestPath) }; +/** A project worktree's managed-mcp.json: `{}` when it is gone, empty or does not parse. */ +async function readProjectMcpManifest(cfg: LocalConfig, projectRoot: string): Promise { + return (await loadProjectMcpManifest(getDataHome(cfg), projectRoot, { dryRun: true })).manifest; } /** * The `files` not proven free of a value teamai resolved (#882), each with why. * A missing file is clean; so is one a tool reads that parses and holds no * server at all. One holding servers is clean only when its worktree's manifest - * is there to say what teamai wrote, and the file holds none of the team's - * servers that need a resolved `${VAR}` there, none of teamai's own entries the - * manifest records and cleanup left (their definition may have left mcp.yaml), - * and none of the values of the variables set in this environment. Anything - * else (no tool reads it, it does not parse, the team's servers cannot be read, - * the manifest is lost) is not: a server teamai wrote, since dropped from - * mcp.yaml, with a value no longer set, looks like the member's own. + * records what teamai wrote to that tool's file (an empty list once teamai took + * its last server out), and the file holds none of the team's servers that need + * a resolved `${VAR}` there, none of teamai's own entries the manifest records + * and cleanup left (their definition may have left mcp.yaml), and none of the + * values of the variables set in this environment. Anything else (no tool reads + * it, it does not parse, the team's servers cannot be read, the manifest is + * lost, empty, does not parse or has no record for the tool) is not: a server + * teamai wrote, since dropped from mcp.yaml, with a value no longer set, looks + * like the member's own. * `before` is `localConfig`'s manifest as it stood before a reconcile rewrote it. * With `otherWorktrees: 'empty'` another worktree's file is clean only when it * holds no server at all: today's definitions and values cannot judge an entry @@ -588,7 +584,7 @@ export async function mcpConfigsNotProvenClean( teamConfig: TeamaiConfig, localConfig: LocalConfig, files: string[], - options: { before?: ProjectMcpRecord; otherWorktrees?: 'judged' | 'empty' } = {}, + options: { before?: ManagedMcpManifest; otherWorktrees?: 'judged' | 'empty' } = {}, ): Promise> { const { before, otherWorktrees = 'judged' } = options; const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); @@ -599,15 +595,17 @@ export async function mcpConfigsNotProvenClean( root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); const ownRoot = await realRoot(localConfig.projectRoot); for (const cfg of await projectWorktreeConfigs(localConfig)) { - const { manifest, recorded } = cfg === localConfig && before ? before - : cfg.projectRoot ? await readProjectMcpRecord(cfg, cfg.projectRoot) - : { manifest: {}, recorded: false }; + const manifest = cfg === localConfig && before ? before + : cfg.projectRoot ? await readProjectMcpManifest(cfg, cfg.projectRoot) + : {}; // This checkout listed again under its real path is not another worktree. const foreign = cfg !== localConfig && await realRoot(cfg.projectRoot) !== ownRoot; for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); - const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); + const records = manifest[managedMcpManifestKey(target.tool, true)]; + const recorded = Array.isArray(records); + const owned = recorded ? records.map((record) => record.name) : []; // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. const seen = targets.get(key); targets.set(key, { @@ -640,7 +638,7 @@ export async function mcpConfigsNotProvenClean( : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] - ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone'); + ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse or has no entry for it'); if (why) held.set(file, why); } return held; @@ -668,7 +666,7 @@ export async function reconcileMcpForConfig( const exclusions = new Map(); const protect = !options.removeAll && !options.dryRun; // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. - const before = protect && localConfig.projectRoot ? await readProjectMcpRecord(localConfig, localConfig.projectRoot) : undefined; + const before = protect && localConfig.projectRoot ? await readProjectMcpManifest(localConfig, localConfig.projectRoot) : undefined; try { return await reconcileTargets(teamConfig, localConfig, options, exclusions); } finally { @@ -688,7 +686,7 @@ async function protectResolvedMcpConfigs( teamConfig: TeamaiConfig, localConfig: LocalConfig, exclusions: Map, - before: ProjectMcpRecord | undefined, + before: ManagedMcpManifest | undefined, ): Promise { const { projectRoot } = localConfig; if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; @@ -707,7 +705,7 @@ async function protectProjectMcpConfigs( localConfig: LocalConfig, projectRoot: string, exclusions: Map, - before: ProjectMcpRecord | undefined, + before: ManagedMcpManifest | undefined, ): Promise { const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); @@ -761,7 +759,7 @@ async function releaseMcpGitExcludes( localConfig: LocalConfig, projectRoot: string, addedNow: string[], - before?: ProjectMcpRecord, + before?: ManagedMcpManifest, ): Promise { const dirs = [projectRoot]; for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) dirs.push(path.dirname(target.file)); @@ -894,7 +892,9 @@ async function reconcileTargets( wrote = await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options) || wrote; } - if (nextRecords.length > 0) manifest[manifestKey] = nextRecords; + // An emptied project record stays: it says teamai owns nothing left in that + // file, which a lost record cannot, and so lets its exclude line go (#882). + if (nextRecords.length > 0 || (target.projectScope && manifest[manifestKey] !== undefined)) manifest[manifestKey] = nextRecords; else delete manifest[manifestKey]; } From 5783dc6e96b7a20df925038ec5d0c63725ac290e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 07:26:07 +0200 Subject: [PATCH 20/85] fix(mcp): keep the exclude line of a config this pull wrote when a later step fails (#882) --- src/__tests__/mcp-reconcile.test.ts | 14 ++++++++++++++ src/mcp-reconcile.ts | 26 ++++++++++++++++---------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 37174fa41..a69c85d5b 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1077,6 +1077,20 @@ servers: expect(vi.mocked(log.debug).mock.calls.flat().join('\n')).toMatch(/\/\.mcp\.json/); }); + it('but one this pull listed stays when it wrote the value and then failed to record it', async () => { + // Shorter than eight characters: no scan of the file can find it again. + vi.stubEnv('SECRET_TOKEN', 'short'); + await writeMcpYaml(withSecret); + beforeJsonWrite.run = async (file) => { + if (path.basename(file) === 'managed-mcp.json') throw new Error('disk full'); + }; + + await expect(reconcileMcpForConfig(teamConfig, claudeOnly())).rejects.toThrow('disk full'); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('Bearer short'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('but one an earlier pull listed stays while the config cannot be proven clean', async () => { await writeMcpYaml(withSecret); await reconcileMcpForConfig(teamConfig, claudeOnly()); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index ec2868071..51260c405 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -664,14 +664,16 @@ export async function reconcileMcpForConfig( ): Promise { // Each project config's exclusion from git, established before a resolved value is written into it. const exclusions = new Map(); + // The project configs this run wrote: a line it added for one stays, whatever fails after. + const written = new Set(); const protect = !options.removeAll && !options.dryRun; // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. const before = protect && localConfig.projectRoot ? await readProjectMcpManifest(localConfig, localConfig.projectRoot) : undefined; try { - return await reconcileTargets(teamConfig, localConfig, options, exclusions); + return await reconcileTargets(teamConfig, localConfig, options, exclusions, written); } finally { // Also after a failed write: what earlier pulls wrote is on disk either way. - if (protect) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions, before); + if (protect) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions, written, before); } } @@ -686,12 +688,13 @@ async function protectResolvedMcpConfigs( teamConfig: TeamaiConfig, localConfig: LocalConfig, exclusions: Map, + written: Set, before: ManagedMcpManifest | undefined, ): Promise { const { projectRoot } = localConfig; if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; try { - await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions, before); + await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions, written, before); } catch (e) { log.warn( `Could not check this project's MCP configs for resolved values to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` @@ -705,6 +708,7 @@ async function protectProjectMcpConfigs( localConfig: LocalConfig, projectRoot: string, exclusions: Map, + written: Set, before: ManagedMcpManifest | undefined, ): Promise { const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); @@ -722,10 +726,11 @@ async function protectProjectMcpConfigs( } // Also a file listed before its write: a concurrent uninstall may have taken its line out since. for (const file of holding) await excludeFromGit(file); - // A line this run added for a file it then wrote no value into restores the file's state before the run. + // A line this run added for a file it then did not write restores the file's state before the run. + // One it wrote holds the value even when no scan finds it (shorter than eight characters). const addedNow = [...unproven].filter((file) => { const exclusion = exclusions.get(file); - return !holding.has(file) && exclusion?.kind === 'excluded' && exclusion.added; + return !holding.has(file) && !written.has(file) && exclusion?.kind === 'excluded' && exclusion.added; }); await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before); } @@ -802,6 +807,7 @@ async function reconcileTargets( localConfig: LocalConfig, options: McpReconcileOptions, exclusions: Map, + written: Set, ): Promise { const changes: McpChange[] = []; let wrote = false; @@ -886,11 +892,11 @@ async function reconcileTargets( } } - if (target.format === 'codex') { - wrote = await applyCodex(target, desired, ownedNames, nextRecords, changes, options) || wrote; - } else { - wrote = await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options) || wrote; - } + const wroteTarget = target.format === 'codex' + ? await applyCodex(target, desired, ownedNames, nextRecords, changes, options) + : await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options); + if (wroteTarget) written.add(target.file); + wrote = wroteTarget || wrote; // An emptied project record stays: it says teamai owns nothing left in that // file, which a lost record cannot, and so lets its exclude line go (#882). From d045b59e2d5810d47527619ed016a0cd426ed79a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 07:27:36 +0200 Subject: [PATCH 21/85] fix(mcp): read a check-ignore error as unsafe unless ls-files proves the config untracked (#882) --- src/__tests__/mcp-git-exclude.test.ts | 34 +++++++++++++++++++++++- src/mcp-git-exclude.ts | 38 +++++++++++++++------------ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index fc8f23e72..706a9c65e 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -10,6 +10,7 @@ vi.mock('../utils/logger.js', () => ({ // Git's own failure modes (unsafe repository, bad config) are hard to stage for one subcommand alone. const failCheckIgnore = vi.hoisted(() => ({ on: false })); +const failLsFiles = vi.hoisted(() => ({ on: false })); vi.mock('../utils/exec.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -17,7 +18,9 @@ vi.mock('../utils/exec.js', async (importOriginal) => { execCommand: (cmd: string, args: string[], opts?: Parameters[2]) => failCheckIgnore.on && args[0] === 'check-ignore' ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository' }) - : actual.execCommand(cmd, args, opts), + : failLsFiles.on && args.includes('ls-files') + ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: index file corrupt' }) + : actual.execCommand(cmd, args, opts), }; }); @@ -51,6 +54,7 @@ describe('teamai block in .git/info/exclude (#882)', () => { afterEach(async () => { failCheckIgnore.on = false; + failLsFiles.on = false; slowExcludeRead.on = false; vi.mocked(log.warn).mockClear(); await fse.remove(repo); @@ -66,6 +70,34 @@ describe('teamai block in .git/info/exclude (#882)', () => { expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/\.mcp\.json$/m); }); + it('excludes a file git answers it does not track', async () => { + failCheckIgnore.on = true; + + expect(await ensureExcludedFromGit(path.join(repo, '.mcp.json'))).toEqual({ kind: 'excluded', added: true }); + expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/\.mcp\.json$/m); + }); + + it('fails for a file git tracks, and writes nothing', async () => { + const file = path.join(repo, '.mcp.json'); + await fse.writeJson(file, {}); + execFileSync('git', ['add', '.mcp.json'], { cwd: repo }); + failCheckIgnore.on = true; + + expect(await ensureExcludedFromGit(file)).toMatchObject({ kind: 'failed', reason: `git already tracks ${file}` }); + expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); + }); + + it('fails with git\'s error, and writes nothing, when git cannot say whether it tracks the file either', async () => { + failCheckIgnore.on = true; + failLsFiles.on = true; + + expect(await ensureExcludedFromGit(path.join(repo, '.mcp.json'))).toMatchObject({ + kind: 'failed', + reason: expect.stringContaining('fatal: index file corrupt'), + }); + expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); + }); + it('warns with the file and git\'s error when it is not', async () => { await fse.writeJson(path.join(repo, '.mcp.json'), {}); await fse.writeFile(path.join(repo, '.git', 'config'), '[core\nbroken\n'); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index a80043de6..3d09622b4 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -108,16 +108,17 @@ export async function gitTracking(file: string): Promise { /** * Whether git tracks `file` (#879): the next `git commit -a` commits a change to - * it, and no exclude rule stops that. Read-only. A file outside any repository - * is not tracked; nor is one in a repository git cannot answer for, where a - * commit fails too. + * it, and no exclude rule stops that. Read-only. `unknown` is git failing to + * answer: never read it as untracked. */ -async function gitTracks(file: string): Promise { +async function gitTracks(file: string): Promise<{ kind: 'tracked' } | { kind: 'untracked' } | { kind: 'unknown'; error: string }> { // The file, or even its directory, may be gone from disk and still be in the index. const dir = await existingAncestor(file); const result = await execCommand('git', ['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) - .catch(() => null); - return result?.code === 0; + .catch((e: unknown) => ({ code: -1, stdout: '', stderr: e instanceof Error ? e.message : String(e) })); + if (result.code === 0) return { kind: 'tracked' }; + if (result.code === 1) return { kind: 'untracked' }; + return { kind: 'unknown', error: result.stderr.trim() || `git exited with ${result.code}` }; } /** @@ -148,15 +149,25 @@ export type GitExclusion = /** * Add `file` to its repository's `.git/info/exclude` unless git ignores it * already, and whether git now leaves it out of a commit. Idempotent; a path - * already ignored, or outside any repository, adds nothing, and one git cannot - * answer for is added all the same, and one git tracks fails before anything - * else is checked. `file` need not exist yet: pull calls this + * already ignored, or outside any repository, adds nothing. One git tracks + * fails before anything else is checked, and so does one git cannot say it + * does not track: an exclude rule does not apply to a tracked file, and a git + * error is never read as safe. `file` need not exist yet: pull calls this * before writing a resolved value into it. `dryRun` writes nothing and reports * what would stop the write. */ export async function ensureExcludedFromGit(file: string, options: { dryRun?: boolean } = {}): Promise { const tracking = await gitTracking(file); if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return { kind: 'excluded', added: false }; + const repair = 'Fix the repository, or add the file to its .git/info/exclude yourself, then run `teamai pull` again.'; + const tracked: GitExclusion = { + kind: 'failed', + reason: `git already tracks ${file}`, + fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }; + const inIndex = await gitTracks(file); + if (inIndex.kind === 'tracked') return tracked; + if (inIndex.kind === 'unknown') return { kind: 'failed', reason: inIndex.error, fix: repair }; // `file` and its directory need not exist yet: git is asked from the nearest one that does. const dir = await existingAncestor(file); const location = await gitExcludeFile(dir); @@ -164,20 +175,13 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo return { kind: 'failed', reason: tracking.kind === 'unknown' ? tracking.error : 'git could not locate .git/info/exclude', - fix: 'Fix the repository, or add the file to its .git/info/exclude yourself, then run `teamai pull` again.', + fix: repair, }; } const { excludeFile } = location; // Anchored at the working tree root, glob characters escaped. const rel = path.relative(dir, file).split(path.sep).join('/'); const pattern = `/${location.prefix}${rel}`.replace(/[\\*?[\]!#]/g, '\\$&'); - // An exclude rule does not apply to a file git tracks already: that fix comes first. - const tracked: GitExclusion = { - kind: 'failed', - reason: `git already tracks ${file}`, - fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, - }; - if (tracking.kind === 'would-commit' && await gitTracks(file)) return tracked; const retry = `Make it writable, or add \`${pattern}\` to it yourself, then run \`teamai pull\` again.`; // A read-only exclude file is the member's choice; the atomic write would replace it all the same. for (const writable of [path.dirname(excludeFile), ...(await pathExists(excludeFile) ? [excludeFile] : [])]) { From b9529d14a4c6027964f146310fa6b167faea5e57 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 07:28:59 +0200 Subject: [PATCH 22/85] fix(mcp): report withheld only for targets delivery would write the server to (#882) --- src/__tests__/mcp-cmd.test.ts | 42 ++++++++++++++++++++++++++++++----- src/mcp-cmd.ts | 13 +++++++---- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index a41a94173..33cb899c5 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -8,12 +8,22 @@ vi.mock('../namespaced-entries.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEntriesFor: vi.fn(), })); -vi.mock('../mcp-reconcile.js', () => ({ - reconcileMcpForConfig: vi.fn(), - releaseCleanMcpGitExcludes: vi.fn(), - resolveMcpTargets: vi.fn().mockResolvedValue([]), - buildVarTable: vi.fn().mockResolvedValue({}), -})); +// The per-target delivery filters stay real: `withheld` must name only where a pull would write. +vi.mock('../mcp-reconcile.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + desiredMcpForTarget: actual.desiredMcpForTarget, + mcpTargetExcluded: actual.mcpTargetExcluded, + reconcileMcpForConfig: vi.fn(), + releaseCleanMcpGitExcludes: vi.fn(), + resolveMcpTargets: vi.fn().mockResolvedValue([]), + buildDesiredMcpContext: vi.fn().mockResolvedValue({ + sharing: { autoApply: true, allowedCommands: [], allowedHosts: [] }, + excluded: new Set(), + vars: { JIRA_TOKEN: 'jira-token-value' }, + }), + }; +}); vi.mock('../mcp-git-exclude.js', async (importOriginal) => ({ ...(await importOriginal()), ensureExcludedFromGit: vi.fn(), @@ -138,6 +148,26 @@ describe('mcpList', () => { expect(text).toContain('withheld: claude — git already tracks /work/app/.mcp.json. Run `git rm --cached /work/app/.mcp.json`'); }); + it('does not say a server is withheld from a tool delivery never writes it to (#882)', async () => { + mockedResolve.mockResolvedValue(resolved([ + [{ name: 'jira', transport: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer ${JIRA_TOKEN}' }, tools: ['cursor'] }, 'mcp/mcp.yaml', null], + ])); + (resolveMcpTargets as Mock).mockResolvedValueOnce([ + { tool: 'claude', format: 'claude', file: '/work/app/.mcp.json', projectScope: true }, + ]); + (ensureExcludedFromGit as Mock).mockResolvedValue({ + kind: 'failed', + reason: 'git already tracks /work/app/.mcp.json', + fix: 'Run `git rm --cached /work/app/.mcp.json`, then `teamai pull` again.', + }); + + try { + expect(await listOutput()).not.toContain('withheld'); + } finally { + (ensureExcludedFromGit as Mock).mockReset(); + } + }); + it('reports a set that cannot be resolved instead of listing part of it', async () => { mockedResolve.mockResolvedValue({ kind: 'failed', diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index 0fdbde58e..ec782d23d 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -6,7 +6,9 @@ import { reconcileMcpForConfig, releaseCleanMcpGitExcludes, resolveMcpTargets, - buildVarTable, + buildDesiredMcpContext, + desiredMcpForTarget, + mcpTargetExcluded, type McpChange, type McpTarget, } from './mcp-reconcile.js'; @@ -42,7 +44,8 @@ export async function mcpList(_options: GlobalOptions): Promise { } const targets = await resolveMcpTargets(teamConfig, localConfig); - const vars = await buildVarTable(localConfig); + const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig); + const { vars } = desiredContext; // Project scope reads THIS worktree's own per-worktree manifest; user the global file. const manifest = (await readJson( managedMcpManifestPath( @@ -75,9 +78,11 @@ export async function mcpList(_options: GlobalOptions): Promise { const installedIn = targets.filter(installed).map((t) => t.tool); console.log(` installed: ${installedIn.length > 0 ? installedIn.join(', ') : '(none)'}`); // Pull writes a resolved value only into a file git leaves out of a commit - // (#882); an entry an earlier pull wrote there stays as it was. + // (#882); an entry an earlier pull wrote there stays as it was. Only where + // delivery would write it: its tools, transport, policy and requirements. for (const t of targets) { - if (!carriesResolvedValue(t, [s], [s.name])) continue; + if (mcpTargetExcluded(localConfig, t)) continue; + if (!carriesResolvedValue(t, [s], desiredMcpForTarget(t, [s], desiredContext).desired.keys())) continue; const exclusion = await ensureExcludedFromGit(t.file, { dryRun: true }); if (exclusion.kind === 'failed') console.log(` withheld: ${t.tool} — ${exclusion.reason}. ${exclusion.fix}`); } From 34bb7c11ce4ec6ce52f0637746b51fb77fa809a2 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 07:30:08 +0200 Subject: [PATCH 23/85] docs(mcp): describe the .git/info/exclude block in the setup skill and the stricter git check (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 12 ++++++++++++ skill-data/setup/references/uninstall.md | 6 ++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 37f0aee1d..9c91b50a7 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot answer for is listed all the same. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing). `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing). `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 276d23d57..f6b3d7c4e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断的路径也会照样写入。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 0f476c8b9..60c27c04d 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -37,6 +37,18 @@ teamai mcp remove # remove teamai-managed MCP servers MCP definitions travel with the team repo like skills/rules — edit, then the members pick them up on sync. +A server with a `${VAR}` the tool cannot expand itself gets the resolved value +written into its project config (`.mcp.json`, `.cursor/mcp.json`, ...). Before +that write, teamai lists the file in the clone's `.git/info/exclude`, inside a +`# [teamai:mcp-exclude:start]` block; the committed `.gitignore` is never touched. +When it cannot (git already tracks the file, `.git/info` is not writable, the +exclude file is held by another teamai command, or git errors), it leaves the file +as it was, warns, and `teamai mcp list` shows `withheld: — . `. +Apply the fix it names (a tracked file: `git rm --cached ` and rotate the +token), then run `teamai pull`. A pull or `teamai mcp remove` takes a line out +once its file no longer holds a resolved value; `teamai uninstall` does so in +every worktree. + ## Invite a member There is **no CLI invite flag.** Inviting is done on the Git platform's website: diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 3f526b93c..4b2c39b3d 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -55,3 +55,9 @@ and give it your team repo URL."* and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, that is the `--agent ` form, not a full uninstall. +- In a project, uninstall also takes teamai's lines out of `.git/info/exclude` + (the `# [teamai:mcp-exclude:start]` block) for MCP configs it proves hold no + resolved `${VAR}` value. For one it cannot prove clean it keeps the line and + warns, naming the file and why: have the user remove teamai's servers from that + file, then delete the line (with the last one, the block's markers). Do not + delete a kept line while its file still holds a token. From 875c7cfc9c922c75dea31594bf9dbf5af452131e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:18:58 +0200 Subject: [PATCH 24/85] fix(mcp): keep the exclude line of an entry a pull wrote with a resolved value after its definition turns literal (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 21 ++++++++ src/__tests__/mcp-reconcile.test.ts | 63 +++++++++++++++++++++++ src/doctor-delivery.ts | 9 ++-- src/mcp-reconcile.ts | 45 ++++++++++++---- src/types.ts | 5 ++ 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 62dc86427..cedd5ad15 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -311,6 +311,27 @@ describe('doctor — MCP servers delivered on disk', () => { expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); }); + it.each([ + ['notes it wrote a resolved value', { resolved: true }], + ['is an older teamai\'s, without that note', {}], + ])('still fails when the server\'s ${VAR} became a literal, its tool is disabled and the record %s', async (_label, note) => { + const { entryHash } = await import('../resources/mcp-format.js'); + const { mcpServers } = await fse.readJson(path.join(projectRoot, '.mcp.json')) as { mcpServers: Record }; + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: entryHash(mcpServers.jira), ...note }], + }); + await writeTeamMcp( + 'servers:\n - name: jira\n transport: http\n url: https://jira.example/mcp\n' + + ' headers:\n Authorization: "Bearer published-literal"\n', + ); + localConfig.disabledAgents = ['claude', 'tclaude']; + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); + }); + it('names a file two tools share once', async () => { teamConfig.toolPaths = { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index a69c85d5b..63e23af52 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1034,6 +1034,69 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + + describe('when its server\'s ${VAR} has since become a literal and the variable is gone', () => { + beforeEach(async () => { + await writeMcpYaml(withSecret.replace('${SECRET_TOKEN}', 'published-literal')); + vi.stubEnv('SECRET_TOKEN', ''); + }); + + it('and its tool is disabled', async () => { + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await fse.readFile(path.join(projectRoot, '.cursor', 'mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + // Claude's copy now holds the literal: nothing resolved is left there. + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + }); + + it('and the team turned automatic MCP delivery off', async () => { + const manual = { ...teamConfig, sharing: { ...teamConfig.sharing, mcp: { autoApply: false } } } as TeamaiConfig; + + await reconcileMcpForConfig(manual, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('and its tool is disabled, recorded by an older teamai that did not note resolved values', async () => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record>>; + await fse.writeJson(manifestFile, Object.fromEntries(Object.entries(manifest).map(([key, records]) => + [key, records.map(({ name, hash }) => ({ name, hash }))]))); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + }); + }); + + it('adds nothing for a disabled tool\'s config whose server never held a resolved value, after its definition changed', async () => { + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + await reconcileMcpForConfig(teamConfig, projectConfig); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/v2\n'); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await fse.readFile(path.join(projectRoot, '.cursor', 'mcp.json'), 'utf-8')).toContain('https://example.com/open'); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + + it('keeps listing a Codex project config after its server\'s ${VAR} became a literal and Codex was disabled', async () => { + const withCodex = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codex: { ...TOOL_PATHS.codex, mcpProject: '.codex/config.toml' } } } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.codex', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codex]\n`); + await reconcileMcpForConfig(withCodex, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.codex', 'config.toml'), 'utf-8')).toContain('super-secret-value'); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'published-literal')} tools: [codex]\n`); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(withCodex, { ...projectConfig, disabledAgents: ['codex'] } as LocalConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.codex\/config\.toml$/m); }); it('lists the config in .git/info/exclude before writing the value into it', async () => { diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index e861a99ba..b419706f1 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -8,6 +8,7 @@ import type { EntryResolution, EntryType } from './namespaced-entries.js'; import { splitFrontmatter } from './utils/frontmatter.js'; import type { ResourceHandler } from './resources/base.js'; import type { Check, DoctorContext } from './doctor.js'; +import type { DesiredMcpContext } from './mcp-reconcile.js'; import { findEnvBlockFor, envBlockSourcesPath, @@ -522,7 +523,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise teamMcpToDef(entry.entry)); let manifest: ManagedMcpManifest | undefined; let vars: Record | undefined; + let desiredContext: Promise | undefined; + const desired = (): Promise => desiredContext ??= buildDesiredMcpContext(teamConfig, localConfig); const holding = new Set(); const tracked: string[] = []; @@ -541,8 +544,8 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise record.name); - if (!await resolvedValueEvidence(target, teamDefs, owned, vars)) continue; + const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; + if (!await resolvedValueEvidence(target, teamDefs, owned, vars, desired)) continue; holding.add(target.file); const tracking = await gitTracking(target.file); if (tracking.kind === 'would-commit') tracked.push(target.file); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 51260c405..fbf566755 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -517,29 +517,51 @@ export async function installedMcpEntries(target: McpTarget): Promise, + ctx: () => Promise, ): Promise { const raw = await readFileSafe(target.file); if (raw === null) return null; const installed = await installedMcpEntries(target); - const present = installed ? owned.filter((name) => installed.has(name)) : owned; + const records = installed ? owned.filter((record) => installed.has(record.name)) : owned; + const present = records.map((record) => record.name); if (!teamDefs) return present.length > 0 ? `teamai's ${present.join(', ')}, and the team's MCP servers cannot be read` : null; const dropped = present.find((name) => !teamDefs.some((def) => def.name === name)); if (dropped) return `teamai's ${dropped}, which has left the team's MCP servers`; const needing = present.find((name) => carriesResolvedValue(target, teamDefs, [name])); if (needing) return `teamai's ${needing}, which needs a resolved \${VAR}`; + // An entry as a pull wrote it holds what that pull resolved, whatever its definition says now. + let desired: Map | undefined; + for (const record of installed ? records : []) { + if (entryHash(installed?.get(record.name)) !== record.hash) continue; + if (record.resolved === true) return `teamai's ${record.name}, as a pull wrote it with a resolved \${VAR}`; + if (record.resolved !== undefined) continue; + // An older teamai did not note it: stale, unless today's definition writes the same entry. + desired ??= desiredMcpForTarget(target, teamDefs, await ctx()).desired; + if (desired.get(record.name)?.hash !== record.hash) { + return `teamai's ${record.name}, which an earlier pull wrote and its current definition no longer produces`; + } + } const variable = resolvedVariableIn(target, teamDefs, vars, raw); return variable ? `the value of $${variable}` : null; } +/** `load`, run once, on the first call. */ +function once(load: () => Promise): () => Promise { + let value: Promise | undefined; + return () => value ??= load(); +} + /** * `localConfig` and, in project scope, one config per other linked worktree: * each worktree has its own MCP configs and managed-mcp manifest. @@ -590,7 +612,7 @@ export async function mcpConfigsNotProvenClean( const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); + const targets = new Map(); const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); const ownRoot = await realRoot(localConfig.projectRoot); @@ -605,7 +627,7 @@ export async function mcpConfigsNotProvenClean( const key = path.join(dir, path.basename(target.file)); const records = manifest[managedMcpManifestKey(target.tool, true)]; const recorded = Array.isArray(records); - const owned = recorded ? records.map((record) => record.name) : []; + const owned = recorded ? records : []; // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. const seen = targets.get(key); targets.set(key, { @@ -619,6 +641,7 @@ export async function mcpConfigsNotProvenClean( // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. const identity = new Set(['USER', 'LOGNAME', 'USERNAME']); const vars = await buildVarTable(localConfig); + const ctx = once(() => buildDesiredMcpContext(teamConfig, localConfig)); const values = Object.entries(vars) .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)); const held = new Map(); @@ -636,7 +659,7 @@ export async function mcpConfigsNotProvenClean( : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' : !teamDefs ? 'the team\'s MCP servers cannot be read' : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` - : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars).then((e) => e && `it holds ${e}`) + : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars, ctx).then((e) => e && `it holds ${e}`) ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse or has no entry for it'); if (why) held.set(file, why); @@ -715,13 +738,14 @@ async function protectProjectMcpConfigs( const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const vars = await buildVarTable(localConfig); + const ctx = once(() => buildDesiredMcpContext(teamConfig, localConfig)); const holding = new Set(); const unproven = new Set(); for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { // Tried before its write this run, and reported there. if (exclusions.get(target.file)?.kind === 'failed') continue; - const owned = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).map((record) => record.name); - if (await resolvedValueEvidence(target, teamDefs, owned, vars)) holding.add(target.file); + const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; + if (await resolvedValueEvidence(target, teamDefs, owned, vars, ctx)) holding.add(target.file); else unproven.add(target.file); } // Also a file listed before its write: a concurrent uninstall may have taken its line out since. @@ -898,6 +922,9 @@ async function reconcileTargets( if (wroteTarget) written.add(target.file); wrote = wroteTarget || wrote; + // Whether each entry holds a resolved value: once its definition stops + // needing one, what this pull wrote still does (#882). + if (target.projectScope) for (const record of nextRecords) record.resolved ??= carriesResolvedValue(target, teamDefs, [record.name]); // An emptied project record stays: it says teamai owns nothing left in that // file, which a lost record cannot, and so lets its exclude line go (#882). if (nextRecords.length > 0 || (target.projectScope && manifest[manifestKey] !== undefined)) manifest[manifestKey] = nextRecords; diff --git a/src/types.ts b/src/types.ts index b0f4f026d..dcaec2a84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -893,6 +893,11 @@ export interface ManagedMcpRecord { name: string; /** sha1 (first 16 hex) of the rendered entry; drives idempotent rewrites. */ hash: string; + /** + * Project scope: whether the entry holds a `${VAR}` value teamai resolved + * (#882). Absent in records an older teamai wrote. + */ + resolved?: boolean; } /** ~/.teamai/managed-mcp.json — team MCP servers injected per tool+scope key. */ From 7cd4841cf986f06c48938dce7134f3554c89519a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:21:19 +0200 Subject: [PATCH 25/85] fix(mcp): judge a nested repository's linked worktree config by its sibling's tool (#882) --- src/__tests__/mcp-reconcile.test.ts | 20 ++++++++++ src/__tests__/uninstall.test.ts | 58 +++++++++++++++++++++++++++++ src/mcp-git-exclude.ts | 8 +++- src/mcp-reconcile.ts | 58 +++++++++++++++++------------ src/uninstall.ts | 3 +- 5 files changed, 120 insertions(+), 27 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 63e23af52..c1f48d1c9 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1251,6 +1251,26 @@ servers: expect(await excludeOf(projectRoot)).not.toContain('teamai'); }); + it('when `teamai mcp remove` finds a nested repository\'s linked worktree holding no server', async () => { + const cursorDir = path.join(projectRoot, '.cursor'); + git(cursorDir, 'init', '-q'); + git(cursorDir, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'); + const linked = path.join(tmpDir, 'cursor-linked'); + git(cursorDir, 'worktree', 'add', '-q', linked); + await fse.writeJson(path.join(linked, 'mcp.json'), { mcpServers: {} }); + await fse.writeFile(path.join(cursorDir, '.git', 'info', 'exclude'), [ + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n')); + await writeMcpYaml(withSecret); + + await releaseCleanMcpGitExcludes(teamConfig, projectConfig); + + expect(await excludeOf(cursorDir)).not.toContain('teamai'); + }); + it('but not while another worktree\'s copy of the config still holds one', async () => { git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'); const worktree = path.join(await fse.realpath(tmpDir), 'business-wt'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 27b4bfd4c..d16d59a05 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1059,6 +1059,64 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); }); + describe('a nested repository\'s linked worktree (#882)', () => { + async function setupNestedLinked(content: unknown): Promise<{ excludeFile: string; linked: string; cursorDir: string }> { + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + const projectRoot = path.join(tmpDir, 'business-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const cursorDir = path.join(projectRoot, '.cursor'); + await fse.ensureDir(path.join(cursorDir, 'skills')); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + execFileSync('git', ['init', '-q'], { cwd: cursorDir }); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init'], { cwd: cursorDir }); + const linked = path.join(tmpDir, 'cursor-linked'); + execFileSync('git', ['worktree', 'add', '-q', linked], { cwd: cursorDir }); + await fse.writeJson(path.join(linked, 'mcp.json'), content); + const excludeFile = path.join(cursorDir, '.git', 'info', 'exclude'); + await fse.writeFile(excludeFile, [ + 'scratch/', + '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', + '/mcp.json', + '# [teamai:mcp-exclude:end]', + '', + ].join('\n')); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + const teamConfig = makeTeamConfig({ + toolPaths: { cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' } }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + return { excludeFile, linked, cursorDir }; + } + + it('removes the block when the config there holds no server', async () => { + const { excludeFile } = await setupNestedLinked({ mcpServers: {} }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe('scratch/\n'); + }); + + it('keeps the block while the config there holds a server, saying teamai cannot judge it', async () => { + const { excludeFile, linked, cursorDir } = await setupNestedLinked({ mcpServers: { mine: { url: 'https://mine.example/mcp' } } }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toContain('/mcp.json'); + const warning = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)).find((m) => m.includes('/mcp.json')); + expect(warning).toContain(path.join(await fse.realpath(linked), 'mcp.json')); + expect(warning).toContain(`in a linked worktree of the repository at ${await fse.realpath(cursorDir)}`); + }); + }); + it('project-scope uninstall removes the block from a nested repository holding an MCP config (#882)', async () => { const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(tmpDir, 'team-repo'); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 3d09622b4..e98b4ccdc 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -285,15 +285,19 @@ export async function findMcpGitExcludes(dirs: Iterable): Promise`, glob characters escaped (see excludeFromGit). found.set(excludeFile, block.patterns.map((pattern) => { - const rel = pattern.replace(/^\//, '').replace(/\\(.)/g, '$1'); + const rel = mcpExcludePatternPath(pattern); return { pattern, files: [...checkouts].map((root) => path.join(root, rel)) }; })); } return found; } +/** The path from its checkout's root one of teamai's patterns stands for: `/`, glob characters escaped (see ensureExcludedFromGit). */ +export function mcpExcludePatternPath(pattern: string): string { + return pattern.replace(/^\//, '').replace(/\\(.)/g, '$1'); +} + /** * Remove `patterns` from teamai's block in `excludeFile` (one `findMcpGitExcludes` * returned), and the block with its last pattern. diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index fbf566755..59c2823fd 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -51,6 +51,7 @@ import { excludeFromGit, existingAncestor, findMcpGitExcludes, + mcpExcludePatternPath, removeMcpGitExclude, resolvedVariableIn, type GitExclusion, @@ -584,9 +585,11 @@ async function readProjectMcpManifest(cfg: LocalConfig, projectRoot: string): Pr } /** - * The `files` not proven free of a value teamai resolved (#882), each with why. - * A missing file is clean; so is one a tool reads that parses and holds no - * server at all. One holding servers is clean only when its worktree's manifest + * The files of `groups` (the checkouts of one exclude line) not proven free of + * a value teamai resolved (#882), each with why. A missing file is clean; so is + * one a tool reads that parses and holds no server at all, and one in a nested + * repository's linked worktree, read as the file of its line this project maps + * is, that parses and holds none. One holding servers is clean only when its worktree's manifest * records what teamai wrote to that tool's file (an empty list once teamai took * its last server out), and the file holds none of the team's servers that need * a resolved `${VAR}` there, none of teamai's own entries the manifest records @@ -605,7 +608,7 @@ async function readProjectMcpManifest(cfg: LocalConfig, projectRoot: string): Pr export async function mcpConfigsNotProvenClean( teamConfig: TeamaiConfig, localConfig: LocalConfig, - files: string[], + groups: Array<{ pattern: string; files: string[] }>, options: { before?: ManagedMcpManifest; otherWorktrees?: 'judged' | 'empty' } = {}, ): Promise> { const { before, otherWorktrees = 'judged' } = options; @@ -645,24 +648,33 @@ export async function mcpConfigsNotProvenClean( const values = Object.entries(vars) .filter(([name, value]) => value.length >= 8 && !identity.has(name) && !/^([/~]|[A-Za-z]:[\\/])/.test(value)); const held = new Map(); - for (const file of files) { - if (!await pathExists(file)) continue; - const known = targets.get(file); - const installed = known ? await installedMcpEntries(known.target) : null; - const raw = (await readFileSafe(file)) ?? ''; - const named = known && installed && teamDefs - ? [...installed.keys()].find((name) => carriesResolvedValue(known.target, teamDefs, [name])) - : undefined; - const why = !known ? 'no tool teamai knows reads it' - : !installed ? 'it does not parse' - : installed.size === 0 ? undefined - : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' - : !teamDefs ? 'the team\'s MCP servers cannot be read' - : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` - : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars, ctx).then((e) => e && `it holds ${e}`) - ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] - ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse or has no entry for it'); - if (why) held.set(file, why); + for (const { pattern, files } of groups) { + // A file no worktree of this project maps, in a checkout of the same repository as + // one it does: a nested repository's linked worktree, read as that one is. + const siblingFile = files.find((file) => targets.has(file)); + const sibling = siblingFile === undefined ? undefined : targets.get(siblingFile); + const nested = siblingFile && path.join(siblingFile, ...mcpExcludePatternPath(pattern).split('/').map(() => '..')); + for (const file of files) { + if (!await pathExists(file)) continue; + const known = targets.get(file) + ?? (sibling && nested ? { target: { ...sibling.target, file }, owned: [], recorded: false, foreign: true, nested } : undefined); + const installed = known ? await installedMcpEntries(known.target) : null; + const raw = (await readFileSafe(file)) ?? ''; + const named = known && installed && teamDefs + ? [...installed.keys()].find((name) => carriesResolvedValue(known.target, teamDefs, [name])) + : undefined; + const why = !known ? 'no tool teamai knows reads it' + : !installed ? 'it does not parse' + : installed.size === 0 ? undefined + : 'nested' in known ? `it holds MCP servers in a linked worktree of the repository at ${known.nested}, which teamai cannot judge` + : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' + : !teamDefs ? 'the team\'s MCP servers cannot be read' + : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` + : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars, ctx).then((e) => e && `it holds ${e}`) + ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] + ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse or has no entry for it'); + if (why) held.set(file, why); + } } return held; } @@ -799,7 +811,7 @@ async function releaseMcpGitExcludes( const held = await mcpConfigsNotProvenClean( teamConfig, localConfig, - [...excludes.values()].flatMap((entries) => entries.flatMap((entry) => entry.files)), + [...excludes.values()].flat(), { before, otherWorktrees: 'empty' }, ); for (const [excludeFile, entries] of excludes) { diff --git a/src/uninstall.ts b/src/uninstall.ts index eed1b2f36..2fdc5c3f5 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1246,8 +1246,7 @@ export async function uninstall(opts: UninstallOptions): Promise { // `git add -A` would commit a value teamai resolved. if (plan.gitExcludes.size > 0) { const { removeMcpGitExclude } = await import('./mcp-git-exclude.js'); - const allFiles = [...plan.gitExcludes.values()].flatMap((entries) => entries.flatMap((entry) => entry.files)); - const held = await mcpConfigsNotProvenClean(teamConfig, localConfig, allFiles); + const held = await mcpConfigsNotProvenClean(teamConfig, localConfig, [...plan.gitExcludes.values()].flat()); for (const [excludeFile, entries] of plan.gitExcludes) { const clean: string[] = []; for (const { pattern, files } of entries) { From a6cfbee66a5ba89faa8fe27756ef745708ba91b4 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:24:14 +0200 Subject: [PATCH 26/85] feat(mcp): record the project MCP configs a pull wrote a resolved value to in managed-mcp-files.json (#882) --- src/__tests__/mcp-resolved-files.test.ts | 186 +++++++++++++++++++++++ src/mcp-git-exclude.ts | 27 ++-- src/mcp-resolved-files.ts | 156 +++++++++++++++++++ 3 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/mcp-resolved-files.test.ts create mode 100644 src/mcp-resolved-files.ts diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts new file mode 100644 index 000000000..a905f8662 --- /dev/null +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../utils/logger.js', () => ({ + log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), dim: vi.fn() }, +})); + +import { + readResolvedMcpFiles, + recordUnverifiedMcpServers, + resolvedMcpFilesPath, + settleResolvedMcpFiles, + trackResolvedMcpFiles, +} from '../mcp-resolved-files.js'; +import { acquireLock, releaseLock } from '../update.js'; +import type { LocalConfig } from '../types.js'; + +/** The per-worktree record of the project MCP configs teamai wrote a resolved value to (#882). */ +describe('managed-mcp-files.json', () => { + let tmp: string; + let cfg: LocalConfig; + let sidecar: string; + const cursor = (): string => path.join(tmp, 'project', '.cursor', 'mcp.json'); + const custom = (): string => path.join(tmp, 'project', 'team', 'mcp.json'); + + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-mcp-files-')); + cfg = { + repo: { localPath: path.join(tmp, 'team'), remote: 'r' }, + username: 'u', + scope: 'project', + projectRoot: path.join(tmp, 'project'), + dataHome: path.join(tmp, 'data'), + additionalRoles: [], + }; + sidecar = resolvedMcpFilesPath(cfg) ?? ''; + }); + + afterEach(async () => { + await fse.remove(tmp); + }); + + it('lives next to the worktree\'s managed-mcp.json', async () => { + const { managedMcpManifestPath } = await import('../types.js'); + expect(sidecar).toBe(path.join(path.dirname(managedMcpManifestPath(path.join(tmp, 'data'), path.join(tmp, 'project'))), 'managed-mcp-files.json')); + }); + + it.each([ + ['missing', null], + ['not JSON', '{ "version": 1, "files": '], + ['an array', '[]'], + ['without files', '{ "version": 1 }'], + ['a newer version', JSON.stringify({ version: 2, files: { '/x/mcp.json': { tools: ['claude'] } } })], + ])('reads as no files when it is %s', async (_label, content) => { + if (content !== null) await fse.outputFile(sidecar, content); + + expect(await readResolvedMcpFiles(cfg)).toEqual({ version: 1, files: {} }); + }); + + it('drops a relative path and an entry of the wrong shape, keeping the rest', async () => { + await fse.outputJson(sidecar, { + version: 1, + files: { + 'relative/mcp.json': { tools: ['cursor'] }, + [custom()]: { tools: 'cursor' }, + [path.join(tmp, 'project', '.mcp.json')]: { tools: ['claude'], unverified: ['jira', 3] }, + [cursor()]: { tools: ['cursor'], unverified: ['jira'] }, + }, + }); + + expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [cursor()]: { tools: ['cursor'], unverified: ['jira'] } }); + }); + + it('records a file under the lock, 0600, and keeps fields it does not know', async () => { + await fse.outputJson(sidecar, { version: 1, note: 'kept', files: { [cursor()]: { tools: ['cursor'], since: 'kept' } } }); + + expect(await trackResolvedMcpFiles(cfg, [{ tool: 'claude', file: custom() }])).toBe('written'); + expect(await trackResolvedMcpFiles(cfg, [{ tool: 'claude', file: custom() }])).toBe('unchanged'); + + expect(await fse.readJson(sidecar)).toEqual({ + version: 1, + note: 'kept', + files: { [cursor()]: { tools: ['cursor'], since: 'kept' }, [custom()]: { tools: ['claude'] } }, + }); + expect((await fse.stat(sidecar)).mode & 0o777).toBe(0o600); + }); + + it('rewrites one that does not parse from empty', async () => { + await fse.outputFile(sidecar, '{ "version": 1, "files": '); + + expect(await trackResolvedMcpFiles(cfg, [{ tool: 'cursor', file: cursor() }])).toBe('written'); + + expect(await readResolvedMcpFiles(cfg)).toEqual({ version: 1, files: { [cursor()]: { tools: ['cursor'] } } }); + }); + + it('writes nothing while another command holds its lock', async () => { + await fse.ensureDir(path.dirname(sidecar)); + const lock = `${sidecar}.teamai-lock`; + expect(await acquireLock(lock)).toBe(true); + try { + expect(await trackResolvedMcpFiles(cfg, [{ tool: 'cursor', file: cursor() }])).toBe('locked'); + } finally { + await releaseLock(lock); + } + + expect(await fse.pathExists(sidecar)).toBe(false); + }); + + it('keeps every file five concurrent commands record', async () => { + const files = [0, 1, 2, 3, 4].map((i) => path.join(tmp, 'project', `tool-${i}`, 'mcp.json')); + + const results = await Promise.all(files.map((file) => trackResolvedMcpFiles(cfg, [{ tool: 'cursor', file }]))); + + expect(results).toEqual(['written', 'written', 'written', 'written', 'written']); + expect(Object.keys((await readResolvedMcpFiles(cfg)).files).sort()).toEqual([...files].sort()); + }); + + it('notes servers only for a file it already lists', async () => { + await trackResolvedMcpFiles(cfg, [{ tool: 'cursor', file: cursor() }]); + + await recordUnverifiedMcpServers(cfg, [{ file: cursor(), names: ['jira'] }, { file: custom(), names: ['mine'] }]); + + expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [cursor()]: { tools: ['cursor'], unverified: ['jira'] } }); + }); + + it('writes nothing to note when a file lists no servers', async () => { + await trackResolvedMcpFiles(cfg, [{ tool: 'cursor', file: cursor() }]); + + expect(await recordUnverifiedMcpServers(cfg, [{ file: cursor(), names: [] }])).toBe('unchanged'); + }); + + describe('settling it against what the files hold', () => { + beforeEach(async () => { + await fse.outputJson(sidecar, { + version: 1, + files: { + [cursor()]: { tools: ['cursor'], unverified: ['jira', 'mine', 'wiki'] }, + [custom()]: { tools: ['claude'] }, + }, + }); + }); + + it.each([ + ['is gone', { kind: 'missing' } as const], + ['holds no server', { kind: 'parsed', servers: [] } as const], + ])('forgets a file that %s', async (_label, state) => { + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'claude', state, holding: false, owned: [] }]); + + expect(Object.keys((await readResolvedMcpFiles(cfg)).files)).toEqual([cursor()]); + }); + + it('keeps a file that does not parse', async () => { + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'claude', state: { kind: 'unparsable' }, holding: false, owned: [] }]); + + expect(Object.keys((await readResolvedMcpFiles(cfg)).files)).toContain(custom()); + }); + + it('drops a noted server that left the file or that teamai owns again', async () => { + await settleResolvedMcpFiles(cfg, [ + { file: cursor(), tool: 'cursor', state: { kind: 'parsed', servers: ['mine', 'wiki'] }, holding: true, owned: ['wiki'] }, + ]); + + expect((await readResolvedMcpFiles(cfg)).files[cursor()]).toEqual({ tools: ['cursor'], unverified: ['mine'] }); + }); + + it('lists a file holding a resolved value it did not know of', async () => { + const other = path.join(tmp, 'project', '.mcp.json'); + + await settleResolvedMcpFiles(cfg, [ + { file: other, tool: 'claude', state: { kind: 'parsed', servers: ['jira'] }, holding: true, owned: ['jira'] }, + ]); + + expect((await readResolvedMcpFiles(cfg)).files[other]).toEqual({ tools: ['claude'] }); + }); + + it('leaves a file it does not list alone when nothing holds a value there', async () => { + const other = path.join(tmp, 'project', '.mcp.json'); + + expect(await settleResolvedMcpFiles(cfg, [ + { file: other, tool: 'claude', state: { kind: 'parsed', servers: ['open'] }, holding: false, owned: ['open'] }, + ])).toBe('unchanged'); + }); + }); +}); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index e98b4ccdc..556b943c2 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -203,7 +203,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo if (add((await readFileSafe(excludeFile)) ?? '') !== null) return { kind: 'pending' }; result = 'unchanged'; } else { - result = await updateExclude(excludeFile, add); + result = await updateFileLocked(excludeFile, add); } } catch (e) { return { kind: 'failed', reason: `adding it to ${excludeFile} failed: ${e instanceof Error ? e.message : String(e)}`, fix: retry }; @@ -234,19 +234,24 @@ export async function excludeFromGit(file: string): Promise { } } -/** How `updateExclude` left the file: `locked` wrote nothing, another command held it past the wait. */ +/** How `updateFileLocked` left the file: `locked` wrote nothing, another command held it past the wait. */ export type ExcludeUpdate = 'written' | 'unchanged' | 'locked'; /** - * Rewrite `excludeFile` with `edit` (null: leave it as it is), holding a lock - * across the read and an atomic write: the worktrees of a repository share the - * file, so two commands adding different paths must not drop each other's. + * Rewrite `file` with `edit` (null: leave it as it is), holding a lock + * across the read and an atomic write: the worktrees of a repository share + * `.git/info/exclude`, so two commands adding different paths must not drop each other's. * A lock still held after the wait writes nothing: an unlocked write could drop - * the holder's pattern, leaving that path unprotected. + * the holder's pattern, leaving that path unprotected. `mode` forces the file's + * mode; without it the file keeps its own. */ -async function updateExclude(excludeFile: string, edit: (content: string) => string | null): Promise { +export async function updateFileLocked( + file: string, + edit: (content: string) => string | null, + options: { mode?: number } = {}, +): Promise { const { acquireLock, releaseLock } = await import('./update.js'); - const lockPath = `${excludeFile}.teamai-lock`; + const lockPath = `${file}.teamai-lock`; let held = false; for (let attempt = 0; attempt < 25 && !held; attempt++) { held = await acquireLock(lockPath); @@ -254,9 +259,9 @@ async function updateExclude(excludeFile: string, edit: (content: string) => str } if (!held) return 'locked'; try { - const next = edit((await readFileSafe(excludeFile)) ?? ''); + const next = edit((await readFileSafe(file)) ?? ''); if (next === null) return 'unchanged'; - await writeFileAtomic(excludeFile, next); + await writeFileAtomic(file, next, options); return 'written'; } finally { await releaseLock(lockPath); @@ -303,7 +308,7 @@ export function mcpExcludePatternPath(pattern: string): string { * returned), and the block with its last pattern. */ export async function removeMcpGitExclude(excludeFile: string, patterns: string[]): Promise { - return updateExclude(excludeFile, (content) => { + return updateFileLocked(excludeFile, (content) => { const block = splitBlock(content); if (!block) return null; const kept = block.patterns.filter((p) => !patterns.includes(p)); diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts new file mode 100644 index 000000000..ee4d68143 --- /dev/null +++ b/src/mcp-resolved-files.ts @@ -0,0 +1,156 @@ +import path from 'node:path'; +import { z } from 'zod'; +import { getDataHome, managedMcpManifestPath, type LocalConfig } from './types.js'; +import { readFileSafe } from './utils/fs.js'; +import { updateFileLocked, type ExcludeUpdate } from './mcp-git-exclude.js'; + +// ─── Project MCP configs teamai wrote a resolved value to ──── +// +// managed-mcp.json records server names per tool, not paths, so a file an +// earlier pull wrote under a toolPaths mapping the team has since changed is +// no longer anyone's target, and a record rebuilt after it was lost cannot +// tell teamai's stale entries from the member's own (#882). This file, next +// to the worktree's managed-mcp.json, remembers both: each project MCP config +// a pull wrote a resolved value to, by absolute path, with the tools it wrote +// it for, and the servers it found there when it rebuilt a lost record. +// Nothing depends on it to keep a line: missing or unreadable, it reads as +// empty and the rules without it apply. + +export interface ResolvedMcpFile { + /** The tools whose MCP format the file was written in. */ + tools: string[]; + /** Servers in the file when teamai rebuilt its lost record: teamai may have written them. */ + unverified?: string[]; +} + +export interface ResolvedMcpFiles { + version: 1; + /** Keyed by the file's absolute path. */ + files: Record; +} + +/** What a command found in a project MCP config, for `settleResolvedMcpFiles`. */ +export interface McpFileObservation { + file: string; + tool: string; + state: { kind: 'missing' } | { kind: 'unparsable' } | { kind: 'parsed'; servers: readonly string[] }; + /** It may hold a value teamai resolved (resolvedValueEvidence). */ + holding: boolean; + /** The server names managed-mcp.json records for it now. */ + owned: string[]; +} + +// Fields a later teamai adds are carried through a rewrite. +const FileSchema = z.object({ tools: z.array(z.string()), unverified: z.array(z.string()).optional() }).passthrough(); +const SidecarSchema = z.object({ version: z.literal(1), files: z.record(z.unknown()) }).passthrough(); + +type Sidecar = z.infer & { files: Record> }; + +/** `/workspaces//managed-mcp-files.json`, or null outside project scope. */ +export function resolvedMcpFilesPath(cfg: LocalConfig): string | null { + if (cfg.scope !== 'project' || !cfg.projectRoot) return null; + return path.join(path.dirname(managedMcpManifestPath(getDataHome(cfg), cfg.projectRoot)), 'managed-mcp-files.json'); +} + +/** Missing, not JSON, of another shape or version: no files. An entry of the wrong shape, or under a relative path, is left out. */ +function parse(content: string): Sidecar { + let data: unknown; + try { + data = JSON.parse(content); + } catch { + data = null; + } + const parsed = SidecarSchema.safeParse(data); + if (!parsed.success) return { version: 1, files: {} }; + const files: Sidecar['files'] = {}; + for (const [file, value] of Object.entries(parsed.data.files)) { + const entry = FileSchema.safeParse(value); + if (entry.success && path.isAbsolute(file)) files[file] = entry.data; + } + return { ...parsed.data, files }; +} + +/** The files this worktree's pulls wrote a resolved value to. Never throws. */ +export async function readResolvedMcpFiles(cfg: LocalConfig): Promise { + const file = resolvedMcpFilesPath(cfg); + const content = file === null ? null : await readFileSafe(file).catch(() => null); + return { version: 1, files: content === null ? {} : parse(content).files }; +} + +/** + * Apply `edit` to the record under its lock (re-read, atomic write, 0600). + * `edit` returns false to leave it as it is. One that does not parse is + * rewritten from empty. + */ +export async function updateResolvedMcpFiles(cfg: LocalConfig, edit: (files: Record) => boolean): Promise { + const file = resolvedMcpFilesPath(cfg); + if (file === null) return 'unchanged'; + return updateFileLocked(file, (content) => { + const sidecar = parse(content); + return edit(sidecar.files) ? `${JSON.stringify(sidecar, null, 2)}\n` : null; + }, { mode: 0o600 }); +} + +/** Record each file as written with a resolved value, for its tool. */ +export function trackResolvedMcpFiles(cfg: LocalConfig, targets: Array<{ tool: string; file: string }>): Promise { + return updateResolvedMcpFiles(cfg, (files) => { + let changed = false; + for (const { tool, file } of targets) { + const entry = files[file]; + if (entry?.tools.includes(tool)) continue; + files[file] = entry ? { ...entry, tools: [...entry.tools, tool] } : { tools: [tool] }; + changed = true; + } + return changed; + }); +} + +/** + * Note `names`, servers found in a file whose lost record teamai rebuilt, as + * possibly teamai's: only for a file already recorded as holding a resolved value. + */ +export function recordUnverifiedMcpServers(cfg: LocalConfig, found: Array<{ file: string; names: string[] }>): Promise { + return updateResolvedMcpFiles(cfg, (files) => { + let changed = false; + for (const { file, names } of found) { + const entry = files[file]; + const added = names.filter((name) => !entry?.unverified?.includes(name)); + if (!entry || added.length === 0) continue; + entry.unverified = [...entry.unverified ?? [], ...added]; + changed = true; + } + return changed; + }); +} + +/** + * Bring the record up to date with what the files hold: forget a file that is + * gone or holds no server, record one holding a resolved value it did not + * list (written by an older teamai), and drop a noted server that left its + * file or that teamai owns again. A file that does not parse stays as it is. + */ +export function settleResolvedMcpFiles(cfg: LocalConfig, observations: McpFileObservation[]): Promise { + return updateResolvedMcpFiles(cfg, (files) => { + let changed = false; + for (const { file, tool, state, holding, owned } of observations) { + const entry = files[file]; + if (state.kind === 'missing' || (state.kind === 'parsed' && state.servers.length === 0)) { + if (entry) delete files[file]; + changed ||= entry !== undefined; + continue; + } + if (!entry) { + if (holding) files[file] = { tools: [tool] }; + changed ||= holding; + continue; + } + if (state.kind !== 'parsed' || !entry.unverified) continue; + const unverified = entry.unverified.filter((name) => state.servers.includes(name) && !owned.includes(name)); + if (unverified.length === entry.unverified.length) continue; + if (unverified.length > 0) entry.unverified = unverified; + else delete entry.unverified; + changed = true; + } + return changed; + }); +} From 4339efbd6e02efc1648e79b786ce77e540152ef5 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:30:00 +0200 Subject: [PATCH 27/85] fix(mcp): keep protecting a config a pull wrote under a toolPaths mapping the team has since changed (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 16 +++ src/__tests__/mcp-reconcile.test.ts | 94 ++++++++++++++ src/__tests__/uninstall.test.ts | 34 +++++ src/doctor-delivery.ts | 23 ++-- src/mcp-git-exclude.ts | 6 +- src/mcp-reconcile.ts | 143 ++++++++++++++++++++-- src/uninstall.ts | 3 + 7 files changed, 298 insertions(+), 21 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index cedd5ad15..e4ce2e023 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -332,6 +332,22 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); }); + it('fails, naming it once, for a config a pull wrote under a mcpProject the team has since changed', async () => { + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + const old = path.join(projectRoot, '.cursor', 'team-mcp.json'); + await fse.outputJson(old, { + mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer t0ken' } } }, + }); + expect(await trackResolvedMcpFiles(localConfig, [{ tool: 'cursor', file: old }])).toBe('written'); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(old)).toHaveLength(2); + expect(check.fix).not.toContain(path.join(projectRoot, '.mcp.json')); + }); + it('names a file two tools share once', async () => { teamConfig.toolPaths = { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index c1f48d1c9..278b8c743 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1073,6 +1073,65 @@ servers: }); }); + describe('a config an earlier pull wrote under a custom mcpProject the team has since changed', () => { + const custom = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }; + const customFile = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); + const sidecar = async (): Promise> => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + return (await readResolvedMcpFiles(projectConfig)).files; + }; + + beforeEach(async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig({ ...teamConfig, toolPaths: custom } as TeamaiConfig, projectConfig); + expect(await fse.readFile(customFile(), 'utf-8')).toContain('super-secret-value'); + }); + + it.each([ + ['restores the built-in path', TOOL_PATHS], + ['drops the tool', { claude: TOOL_PATHS.claude }], + ['moves it again', { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/other-mcp.json' } }], + ])('is listed again when the team %s', async (_label, toolPaths) => { + // As if written before this release, or listed and since dropped: nothing excludes it. + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig({ ...teamConfig, toolPaths } as TeamaiConfig, projectConfig); + + expect(await fse.readFile(customFile(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/team-mcp\.json/); + }); + + it('keeps its line while it holds a server of the member\'s own', async () => { + await fse.writeJson(customFile(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + }); + + it.each([ + ['it is deleted', () => fse.remove(customFile())], + ['it holds no server', () => fse.writeJson(customFile(), { mcpServers: {} })], + ])('lets its line go, and forgets it, once %s', async (_label, arrange) => { + await arrange(); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + expect(Object.keys(await sidecar())).not.toContain(customFile()); + }); + + it('lets its line go when `teamai mcp remove` finds it holding no server', async () => { + await fse.writeJson(customFile(), { mcpServers: {} }); + + await releaseCleanMcpGitExcludes(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + expect(Object.keys(await sidecar())).not.toContain(customFile()); + }); + }); + it('adds nothing for a disabled tool\'s config whose server never held a resolved value, after its definition changed', async () => { await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); await reconcileMcpForConfig(teamConfig, projectConfig); @@ -1432,6 +1491,41 @@ servers: await reconcileMcpForConfig(teamConfig, projectConfig, { dryRun: true }); expect(await excludeOf(projectRoot)).not.toContain('teamai'); + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + expect(await fse.pathExists(resolvedMcpFilesPath(projectConfig) ?? '')).toBe(false); + }); + + it('records each config it writes a resolved value to, by path, before writing it', async () => { + const { readResolvedMcpFiles, resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + const sidecarAtWrite = new Map(); + beforeJsonWrite.run = async (file) => { + sidecarAtWrite.set(file, file in (await readResolvedMcpFiles(projectConfig)).files); + }; + await writeMcpYaml(withSecret); + + try { + await reconcileMcpForConfig(teamConfig, projectConfig); + } finally { + beforeJsonWrite.run = null; + } + + expect(sidecarAtWrite.get(path.join(projectRoot, '.mcp.json'))).toBe(true); + expect((await readResolvedMcpFiles(projectConfig)).files).toEqual({ + [path.join(projectRoot, '.mcp.json')]: { tools: ['claude'] }, + [path.join(projectRoot, '.cursor', 'mcp.json')]: { tools: ['cursor'] }, + }); + expect((await fse.stat(resolvedMcpFilesPath(projectConfig) ?? '')).mode & 0o777).toBe(0o600); + }); + + it('records a config an older teamai wrote a resolved value to on the first pull that finds it', async () => { + const { readResolvedMcpFiles, resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect((await readResolvedMcpFiles(projectConfig)).files[path.join(projectRoot, '.cursor', 'mcp.json')]).toEqual({ tools: ['cursor'] }); }); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index d16d59a05..c09a113f9 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1002,6 +1002,40 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); }); + describe('for a config a pull wrote under a mcpProject the team has since changed', () => { + const oldBlock = block.replace('/.mcp.json', '/.cursor/team-mcp.json'); + + async function setupRecorded(content: unknown): Promise<{ excludeFile: string; old: string }> { + const { projectRoot, excludeFile, localConfig } = await setup(); + const old = path.join(projectRoot, '.cursor', 'team-mcp.json'); + await fse.outputJson(old, content); + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + expect(await trackResolvedMcpFiles(localConfig, [{ tool: 'cursor', file: old }])).toBe('written'); + await fse.writeFile(excludeFile, oldBlock); + return { excludeFile, old }; + } + + it('keeps the block while it holds a server, naming it', async () => { + const { excludeFile, old } = await setupRecorded({ mcpServers: { jira } }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(oldBlock); + const warning = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)).find((m) => m.includes('team-mcp.json')); + expect(warning).toContain(path.join(await fse.realpath(path.dirname(old)), 'team-mcp.json')); + expect(warning).toContain('earlier toolPaths mapping'); + }); + + it('removes the block once it holds no server', async () => { + const { excludeFile } = await setupRecorded({ mcpServers: {} }); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(''); + }); + }); + it('removes the block once uninstall has taken teamai\'s servers out of .mcp.json', async () => { const { homeDir, projectRoot, excludeFile, localConfig } = await setup(); // A path and the login name are in the environment and in ordinary configs: neither holds the block. diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index b419706f1..655fa316c 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -523,7 +523,9 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise(); const tracked: string[] = []; + const hold = async (file: string): Promise => { + holding.add(file); + const tracking = await gitTracking(file); + if (tracking.kind === 'would-commit') tracked.push(file); + else if (tracking.kind === 'unknown') tracked.push(`${file} (git failed: ${tracking.error})`); + }; // Every tool's file, delivery on or off, the same files and evidence pull protects. Two tools may share one. - for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { + const targets = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); + for (const target of targets) { if (holding.has(target.file) || !await pathExists(target.file)) continue; manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; vars ??= await buildVarTable(localConfig); const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; - if (!await resolvedValueEvidence(target, teamDefs, owned, vars, desired)) continue; - holding.add(target.file); - const tracking = await gitTracking(target.file); - if (tracking.kind === 'would-commit') tracked.push(target.file); - else if (tracking.kind === 'unknown') tracked.push(`${target.file} (git failed: ${tracking.error})`); + if (await resolvedValueEvidence(target, teamDefs, owned, vars, desired)) await hold(target.file); + } + // And a file a pull wrote under a mapping the team has since changed. + for (const [file, group] of await recordedMcpTargets(localConfig, targets)) { + if (await recordedMcpFileEvidence(group)) await hold(file); } if (holding.size === 0) return []; diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 556b943c2..49909121b 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -220,15 +220,15 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo } /** - * `ensureExcludedFromGit` for a file already on disk, warning when it fails - * rather than failing the sync that wrote the file. + * `ensureExcludedFromGit` for a file already on disk that may hold a resolved + * value, warning when it fails rather than failing the sync that wrote the file. */ export async function excludeFromGit(file: string): Promise { if (!await pathExists(file)) return; const exclusion = await ensureExcludedFromGit(file); if (exclusion.kind === 'failed') { log.warn( - `${file} holds a resolved MCP variable, and teamai could not keep it out of git: ${exclusion.reason}. ` + `${file} may hold a resolved MCP variable, and teamai could not keep it out of git: ${exclusion.reason}. ` + `${exclusion.fix} Do not commit the file meanwhile.`, ); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 59c2823fd..ed71e8a5a 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -57,6 +57,12 @@ import { type GitExclusion, } from './mcp-git-exclude.js'; import { listWorktrees } from './utils/git.js'; +import { + readResolvedMcpFiles, + settleResolvedMcpFiles, + trackResolvedMcpFiles, + type McpFileObservation, +} from './mcp-resolved-files.js'; // ─── Reconcile engine ──────────────────────────────────────── // @@ -563,6 +569,84 @@ function once(load: () => Promise): () => Promise { return () => value ??= load(); } +/** + * The files `cfg`'s worktree recorded writing a resolved value to (#882) that + * no target in `known` is: the team has since changed or removed the + * toolPaths mapping they were written under. Each with a target per tool it + * was written for. + */ +export async function recordedMcpTargets(cfg: LocalConfig, known: McpTarget[]): Promise> { + const mapped = new Set(await Promise.all(known.map((target) => realFilePath(target.file)))); + const recorded = new Map(); + for (const [file, { tools }] of Object.entries((await readResolvedMcpFiles(cfg)).files)) { + if (mapped.has(await realFilePath(file))) continue; + const targets = tools.flatMap((tool): McpTarget[] => { + const format = detectMcpFormat(tool); + return format ? [{ tool, format, file, projectScope: true }] : []; + }); + if (targets.length > 0) recorded.set(file, targets); + } + return recorded; +} + +/** What one file, read in the format of each of `targets` (all for that file), holds. */ +async function mcpFileState(targets: McpTarget[]): Promise { + const servers = new Set(); + for (const target of targets) { + if (!await pathExists(target.file)) return { kind: 'missing' }; + const installed = await installedMcpEntries(target); + if (!installed) return { kind: 'unparsable' }; + for (const name of installed.keys()) servers.add(name); + } + return { kind: 'parsed', servers: [...servers] }; +} + +/** + * Why a file `recordedMcpTargets` returned may still hold a value teamai + * resolved, or null once it is gone or holds no server: with no tool's + * definitions to judge its entries by, any server it holds may be teamai's. + */ +export async function recordedMcpFileEvidence(targets: McpTarget[]): Promise { + const state = await mcpFileState(targets); + if (state.kind === 'unparsable') return 'it does not parse'; + return state.kind === 'parsed' && state.servers.length > 0 + ? 'teamai wrote a resolved value to it under an earlier toolPaths mapping, and it still holds MCP servers' + : null; +} + +/** + * What each of this worktree's project MCP configs holds, for + * `settleResolvedMcpFiles`: each of `targets`' files, judged by `holds`, and + * each file `recordedMcpTargets` returns, by `recordedMcpFileEvidence`. + */ +async function observeMcpConfigs( + localConfig: LocalConfig, + targets: McpTarget[], + manifest: ManagedMcpManifest, + holds: (target: McpTarget, owned: ManagedMcpRecord[]) => Promise, +): Promise { + const observations: McpFileObservation[] = []; + for (const target of targets) { + const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; + const state = await mcpFileState([target]); + observations.push({ file: target.file, tool: target.tool, state, holding: await holds(target, owned), owned: owned.map((r) => r.name) }); + } + for (const [file, group] of await recordedMcpTargets(localConfig, targets)) { + const holding = await recordedMcpFileEvidence(group) !== null; + const state = await mcpFileState(group); + for (const { tool } of group) observations.push({ file, tool, state, holding, owned: [] }); + } + return observations; +} + +/** `settleResolvedMcpFiles`, which only ever brings the record closer to the disk: a failure waits for the next pull. */ +async function settleRecordedMcpConfigs(localConfig: LocalConfig, observations: McpFileObservation[]): Promise { + const result = await settleResolvedMcpFiles(localConfig, observations).catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result !== 'written' && result !== 'unchanged') { + log.debug(`Did not update managed-mcp-files.json: ${result === 'locked' ? 'another teamai command held it past the wait' : result}. The next pull tries again.`); + } +} + /** * `localConfig` and, in project scope, one config per other linked worktree: * each worktree has its own MCP configs and managed-mcp manifest. @@ -589,7 +673,9 @@ async function readProjectMcpManifest(cfg: LocalConfig, projectRoot: string): Pr * a value teamai resolved (#882), each with why. A missing file is clean; so is * one a tool reads that parses and holds no server at all, and one in a nested * repository's linked worktree, read as the file of its line this project maps - * is, that parses and holds none. One holding servers is clean only when its worktree's manifest + * is, that parses and holds none, and one a worktree recorded writing a + * resolved value to under a toolPaths mapping since changed (managed-mcp-files.json) + * that parses and holds none. One a tool reads holding servers is clean only when its worktree's manifest * records what teamai wrote to that tool's file (an empty list once teamai took * its last server out), and the file holds none of the team's servers that need * a resolved `${VAR}` there, none of teamai's own entries the manifest records @@ -619,13 +705,16 @@ export async function mcpConfigsNotProvenClean( const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); const ownRoot = await realRoot(localConfig.projectRoot); + const recordedBy = new Map(); for (const cfg of await projectWorktreeConfigs(localConfig)) { const manifest = cfg === localConfig && before ? before : cfg.projectRoot ? await readProjectMcpManifest(cfg, cfg.projectRoot) : {}; // This checkout listed again under its real path is not another worktree. const foreign = cfg !== localConfig && await realRoot(cfg.projectRoot) !== ownRoot; - for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { + const cfgTargets = await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true }); + recordedBy.set(cfg, cfgTargets); + for (const target of cfgTargets) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); const records = manifest[managedMcpManifestKey(target.tool, true)]; @@ -641,6 +730,14 @@ export async function mcpConfigsNotProvenClean( }); } } + // Files a pull wrote under a mapping since changed, in any worktree: nothing but the file itself can judge them. + const recorded = new Map(); + for (const [cfg, cfgTargets] of recordedBy) { + for (const [file, group] of await recordedMcpTargets(cfg, cfgTargets)) { + const key = await realFilePath(file); + if (!targets.has(key)) recorded.set(key, group); + } + } // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. const identity = new Set(['USER', 'LOGNAME', 'USERNAME']); const vars = await buildVarTable(localConfig); @@ -656,6 +753,12 @@ export async function mcpConfigsNotProvenClean( const nested = siblingFile && path.join(siblingFile, ...mcpExcludePatternPath(pattern).split('/').map(() => '..')); for (const file of files) { if (!await pathExists(file)) continue; + const earlier = recorded.get(file); + if (earlier) { + const why = await recordedMcpFileEvidence(earlier); + if (why) held.set(file, why); + continue; + } const known = targets.get(file) ?? (sibling && nested ? { target: { ...sibling.target, file }, owned: [], recorded: false, foreign: true, nested } : undefined); const installed = known ? await installedMcpEntries(known.target) : null; @@ -751,15 +854,13 @@ async function protectProjectMcpConfigs( const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const vars = await buildVarTable(localConfig); const ctx = once(() => buildDesiredMcpContext(teamConfig, localConfig)); - const holding = new Set(); - const unproven = new Set(); - for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) { - // Tried before its write this run, and reported there. - if (exclusions.get(target.file)?.kind === 'failed') continue; - const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; - if (await resolvedValueEvidence(target, teamDefs, owned, vars, ctx)) holding.add(target.file); - else unproven.add(target.file); - } + // Tried before its write this run, and reported there. + const targets = (await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) + .filter((target) => exclusions.get(target.file)?.kind !== 'failed'); + const observations = await observeMcpConfigs(localConfig, targets, manifest, + async (target, owned) => await resolvedValueEvidence(target, teamDefs, owned, vars, ctx) !== null); + const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); + const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); // Also a file listed before its write: a concurrent uninstall may have taken its line out since. for (const file of holding) await excludeFromGit(file); // A line this run added for a file it then did not write restores the file's state before the run. @@ -769,6 +870,8 @@ async function protectProjectMcpConfigs( return !holding.has(file) && !written.has(file) && exclusion?.kind === 'excluded' && exclusion.added; }); await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before); + // After the release, which reads the files recorded before this run; also lists one an older teamai wrote. + await settleRecordedMcpConfigs(localConfig, observations); } /** @@ -782,6 +885,9 @@ export async function releaseCleanMcpGitExcludes(teamConfig: TeamaiConfig, local if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; try { await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, []); + const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const targets = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); + await settleRecordedMcpConfigs(localConfig, await observeMcpConfigs(localConfig, targets, manifest, async () => false)); } catch (e) { log.warn( `Could not check whether this project's MCP configs still need their .git/info/exclude lines: ${e instanceof Error ? e.message : String(e)}. ` @@ -804,6 +910,7 @@ async function releaseMcpGitExcludes( ): Promise { const dirs = [projectRoot]; for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) dirs.push(path.dirname(target.file)); + for (const file of Object.keys((await readResolvedMcpFiles(localConfig)).files)) dirs.push(path.dirname(file)); const excludes = await findMcpGitExcludes(dirs); if (excludes.size === 0) return; // Keyed as findMcpGitExcludes keys them: by real path (macOS /var). @@ -926,6 +1033,8 @@ async function reconcileTargets( ); continue; } + // Recorded before the write, so a later change to toolPaths still finds the file. + if (!options.dryRun) await recordResolvedMcpFile(localConfig, target); } const wroteTarget = target.format === 'codex' @@ -949,6 +1058,18 @@ async function reconcileTargets( return { changes, wrote }; } +/** + * `trackResolvedMcpFiles` for a file about to get a resolved value. A failure + * does not stop the write: the exclusion protects the file, and the next pull + * records it. + */ +async function recordResolvedMcpFile(localConfig: LocalConfig, target: McpTarget): Promise { + const result = await trackResolvedMcpFiles(localConfig, [target]).catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result !== 'written' && result !== 'unchanged') { + log.debug(`Did not record ${target.file} in managed-mcp-files.json: ${result === 'locked' ? 'another teamai command held it past the wait' : result}. The next pull records it.`); + } +} + // ─── Appliers ──────────────────────────────────────────────── async function applyJson( diff --git a/src/uninstall.ts b/src/uninstall.ts index 2fdc5c3f5..91d7c9974 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -720,10 +720,13 @@ async function buildRemovalPlan( if (localConfig.scope === 'project') { const { resolveMcpTargets, projectWorktreeConfigs } = await import('./mcp-reconcile.js'); const { findMcpGitExcludes } = await import('./mcp-git-exclude.js'); + const { readResolvedMcpFiles } = await import('./mcp-resolved-files.js'); const dirs: string[] = []; for (const cfg of await projectWorktreeConfigs(localConfig)) { if (cfg.projectRoot) dirs.push(cfg.projectRoot); for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) dirs.push(path.dirname(target.file)); + // A file a pull wrote under a toolPaths mapping since changed. + for (const file of Object.keys((await readResolvedMcpFiles(cfg)).files)) dirs.push(path.dirname(file)); } plan.gitExcludes = await findMcpGitExcludes(dirs); } From 5c46d8acc39938f66cf2ab35c4eb339d8e435591 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:32:59 +0200 Subject: [PATCH 28/85] fix(mcp): keep a config's exclude line past the pull that rebuilt its lost record (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 16 ++++++ src/__tests__/mcp-reconcile.test.ts | 63 +++++++++++++++++++++++ src/__tests__/uninstall.test.ts | 21 ++++++++ src/doctor-delivery.ts | 6 ++- src/mcp-reconcile.ts | 59 +++++++++++++++++---- 5 files changed, 155 insertions(+), 10 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index e4ce2e023..cf6943bcf 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -348,6 +348,22 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).not.toContain(path.join(projectRoot, '.mcp.json')); }); + it('fails for a server that was in the file when a pull rebuilt the lost record, after it left mcp.yaml', async () => { + const { trackResolvedMcpFiles, recordUnverifiedMcpServers } = await import('../mcp-resolved-files.js'); + const file = path.join(projectRoot, '.mcp.json'); + await trackResolvedMcpFiles(localConfig, [{ tool: 'claude', file }]); + expect(await recordUnverifiedMcpServers(localConfig, [{ file, names: ['jira'] }])).toBe('written'); + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'h' }], + }); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(file); + }); + it('names a file two tools share once', async () => { teamConfig.toolPaths = { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 278b8c743..ac9e6b8c2 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1241,6 +1241,22 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('and every pull after the one that rewrote the manifest it had lost', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(managedMcpManifestPath(getDataHome(projectConfig), projectRoot)); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + describe.each([ ['empty', ''], ['truncated', '{ "claude:project": [ { "name": "with-sec'], @@ -1272,6 +1288,53 @@ servers: expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + + it('and a later pull runs after one rebuilt the record for another server', async () => { + await writeMcpYaml(open); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('and `teamai mcp remove` runs after a pull rebuilt the record for another server', async () => { + await writeMcpYaml(open); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + await reconcileMcpForConfig(teamConfig, claudeOnly(), { removeAll: true }); + await releaseCleanMcpGitExcludes(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('until the member takes that server out of the config', async () => { + await writeMcpYaml(open); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + const doc = await fse.readJson(mcpJson()) as { mcpServers: Record }; + delete doc.mcpServers['with-secret']; + await fse.writeJson(mcpJson(), doc); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + }); + + it('when a server of the member\'s own was in the config before teamai first wrote to it', async () => { + await fse.writeJson(mcpJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readJson(mcpJson())).toEqual({ + mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' }, open: expect.anything() }, + }); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); }); it('when `teamai mcp remove` takes teamai\'s servers out of a config that also holds the member\'s own', async () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index c09a113f9..d02f7932f 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1002,6 +1002,27 @@ describe('uninstall', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); }); + it('keeps the block, naming the server, while the config holds one that was there when a pull rebuilt the lost record', async () => { + const { repoPath, projectRoot, excludeFile, localConfig } = await setup(); + await fse.outputFile(path.join(repoPath, 'mcp', 'mcp.yaml'), 'servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + const file = path.join(projectRoot, '.mcp.json'); + await fse.writeJson(file, { mcpServers: { jira, docs: { type: 'http', url: 'https://docs.example/mcp' } } }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'h' }], + }); + const { trackResolvedMcpFiles, recordUnverifiedMcpServers } = await import('../mcp-resolved-files.js'); + await trackResolvedMcpFiles(localConfig, [{ tool: 'claude', file }]); + expect(await recordUnverifiedMcpServers(localConfig, [{ file, names: ['jira'] }])).toBe('written'); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readJson(file)).toEqual({ mcpServers: { jira } }); + expect(await fse.readFile(excludeFile, 'utf8')).toBe(block); + const warning = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)).find((m) => m.includes('/.mcp.json')); + expect(warning).toContain('jira'); + }); + describe('for a config a pull wrote under a mcpProject the team has since changed', () => { const oldBlock = block.replace('/.mcp.json', '/.cursor/team-mcp.json'); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 655fa316c..bcea9973a 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -9,6 +9,7 @@ import { splitFrontmatter } from './utils/frontmatter.js'; import type { ResourceHandler } from './resources/base.js'; import type { Check, DoctorContext } from './doctor.js'; import type { DesiredMcpContext } from './mcp-reconcile.js'; +import type { ResolvedMcpFile } from './mcp-resolved-files.js'; import { findEnvBlockFor, envBlockSourcesPath, @@ -526,6 +527,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise teamMcpToDef(entry.entry)); let manifest: ManagedMcpManifest | undefined; let vars: Record | undefined; + let ledger: Record | undefined; let desiredContext: Promise | undefined; const desired = (): Promise => desiredContext ??= buildDesiredMcpContext(teamConfig, localConfig); @@ -553,8 +556,9 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise, ctx: () => Promise, ): Promise { const raw = await readFileSafe(target.file); if (raw === null) return null; const installed = await installedMcpEntries(target); - const records = installed ? owned.filter((record) => installed.has(record.name)) : owned; + const records = installed ? ledger.owned.filter((record) => installed.has(record.name)) : ledger.owned; const present = records.map((record) => record.name); + const unverified = (ledger.unverified ?? []).find((name) => !installed || installed.has(name)); + if (unverified) return `${unverified}, which was in the file when teamai rebuilt its lost record, so teamai cannot tell whether a pull wrote it`; if (!teamDefs) return present.length > 0 ? `teamai's ${present.join(', ')}, and the team's MCP servers cannot be read` : null; const dropped = present.find((name) => !teamDefs.some((def) => def.name === name)); if (dropped) return `teamai's ${dropped}, which has left the team's MCP servers`; @@ -701,7 +706,7 @@ export async function mcpConfigsNotProvenClean( const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); + const targets = new Map(); const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); const ownRoot = await realRoot(localConfig.projectRoot); @@ -714,6 +719,7 @@ export async function mcpConfigsNotProvenClean( const foreign = cfg !== localConfig && await realRoot(cfg.projectRoot) !== ownRoot; const cfgTargets = await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true }); recordedBy.set(cfg, cfgTargets); + const { files: ledger } = await readResolvedMcpFiles(cfg); for (const target of cfgTargets) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); @@ -725,6 +731,7 @@ export async function mcpConfigsNotProvenClean( targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], + unverified: [...seen?.unverified ?? [], ...ledger[target.file]?.unverified ?? []], recorded: recorded || seen?.recorded === true, foreign: foreign || seen?.foreign === true, }); @@ -760,7 +767,7 @@ export async function mcpConfigsNotProvenClean( continue; } const known = targets.get(file) - ?? (sibling && nested ? { target: { ...sibling.target, file }, owned: [], recorded: false, foreign: true, nested } : undefined); + ?? (sibling && nested ? { target: { ...sibling.target, file }, owned: [], unverified: [], recorded: false, foreign: true, nested } : undefined); const installed = known ? await installedMcpEntries(known.target) : null; const raw = (await readFileSafe(file)) ?? ''; const named = known && installed && teamDefs @@ -773,7 +780,7 @@ export async function mcpConfigsNotProvenClean( : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' : !teamDefs ? 'the team\'s MCP servers cannot be read' : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` - : await resolvedValueEvidence(known.target, teamDefs, known.owned, vars, ctx).then((e) => e && `it holds ${e}`) + : await resolvedValueEvidence(known.target, teamDefs, known, vars, ctx).then((e) => e && `it holds ${e}`) ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse or has no entry for it'); if (why) held.set(file, why); @@ -857,8 +864,9 @@ async function protectProjectMcpConfigs( // Tried before its write this run, and reported there. const targets = (await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) .filter((target) => exclusions.get(target.file)?.kind !== 'failed'); - const observations = await observeMcpConfigs(localConfig, targets, manifest, - async (target, owned) => await resolvedValueEvidence(target, teamDefs, owned, vars, ctx) !== null); + const { files: ledger } = await readResolvedMcpFiles(localConfig); + const observations = await observeMcpConfigs(localConfig, targets, manifest, async (target, owned) => + await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null); const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); // Also a file listed before its write: a concurrent uninstall may have taken its line out since. @@ -1004,6 +1012,9 @@ async function reconcileTargets( if (teamDefs.length === 0 && nothingOwned) return { changes, wrote }; const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); + // The files an earlier pull recorded, and each record this run rebuilds after it was lost (#882). + const listed = projectScope && !options.dryRun ? new Set(Object.keys((await readResolvedMcpFiles(localConfig)).files)) : new Set(); + const rebuilt: Array<{ target: McpTarget; recorded: string[] }> = []; for (const target of targets) { // Same enabledAgents / disabledAgents gate as the other resource syncs. The @@ -1046,6 +1057,9 @@ async function reconcileTargets( // Whether each entry holds a resolved value: once its definition stops // needing one, what this pull wrote still does (#882). if (target.projectScope) for (const record of nextRecords) record.resolved ??= carriesResolvedValue(target, teamDefs, [record.name]); + if (listed.has(target.file) && manifest[manifestKey] === undefined && nextRecords.length > 0) { + rebuilt.push({ target, recorded: nextRecords.map((record) => record.name) }); + } // An emptied project record stays: it says teamai owns nothing left in that // file, which a lost record cannot, and so lets its exclude line go (#882). if (nextRecords.length > 0 || (target.projectScope && manifest[manifestKey] !== undefined)) manifest[manifestKey] = nextRecords; @@ -1053,11 +1067,38 @@ async function reconcileTargets( } if (!options.dryRun && wrote) { + // Before the manifest: once it is written, nothing says the record was rebuilt. + await noteUnverifiedMcpServers(localConfig, rebuilt); await writeJsonAtomic(manifestPath, manifest); } return { changes, wrote }; } +/** + * Note, for each file whose lost record this run rebuilt, the servers in it + * the new record does not claim: a stale entry teamai wrote looks like the + * member's own once its value is no longer set (#882). A failure does not + * stop the manifest write, which the next pull needs to own what this one wrote. + */ +async function noteUnverifiedMcpServers(localConfig: LocalConfig, rebuilt: Array<{ target: McpTarget; recorded: string[] }>): Promise { + if (rebuilt.length === 0) return; + const found: Array<{ file: string; names: string[] }> = []; + for (const { target, recorded } of rebuilt) { + const installed = await installedMcpEntries(target); + found.push({ file: target.file, names: [...installed?.keys() ?? []].filter((name) => !recorded.includes(name)) }); + } + const result = await recordUnverifiedMcpServers(localConfig, found).catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result === 'written' || result === 'unchanged') return; + const files = found.filter((f) => f.names.length > 0).map((f) => f.file); + if (files.length === 0) return; + log.warn( + `Could not note the MCP servers teamai found in ${files.join(', ')} while rebuilding its lost record of them: ` + + `${result === 'locked' ? 'another teamai command held managed-mcp-files.json past the wait' : result}. ` + + 'A later pull may take the file out of .git/info/exclude while a server an earlier pull wrote there is still in it: ' + + 'remove the servers you did not add yourself, and do not commit the file meanwhile.', + ); +} + /** * `trackResolvedMcpFiles` for a file about to get a resolved value. A failure * does not stop the write: the exclusion protects the file, and the next pull From c43085b9e93475e5a8402aac9079b364e7e5c018 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:33:43 +0200 Subject: [PATCH 29/85] test(mcp): pin today's exclude rules for a missing, corrupt or locked managed-mcp-files.json (#882) --- src/__tests__/mcp-reconcile.test.ts | 86 +++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index ac9e6b8c2..e822de143 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1580,6 +1580,92 @@ servers: expect((await fse.stat(resolvedMcpFilesPath(projectConfig) ?? '')).mode & 0o777).toBe(0o600); }); + describe('without a usable managed-mcp-files.json, as before it existed', () => { + const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); + const claudeOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + const open = 'servers:\n - name: open\n transport: http\n url: https://example.com/open\n'; + const sidecarFile = async (): Promise => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + return resolvedMcpFilesPath(projectConfig) ?? ''; + }; + const loseManifest = async (): Promise => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(managedMcpManifestPath(getDataHome(projectConfig), projectRoot)); + }; + + it('keeps the line of a config under a changed mapping it can no longer find, even holding no server', async () => { + const custom = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }; + await writeMcpYaml(withSecret); + await reconcileMcpForConfig({ ...teamConfig, toolPaths: custom } as TeamaiConfig, projectConfig); + await fse.remove(await sidecarFile()); + await fse.writeJson(path.join(projectRoot, '.cursor', 'team-mcp.json'), { mcpServers: {} }); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + // No tool reads it any more, and nothing says teamai wrote it: the line stays, as before. + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + }); + + it('rewrites one that does not parse', async () => { + await fse.outputFile(await sidecarFile(), '{ "version": 1, "files": '); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + expect(Object.keys((await readResolvedMcpFiles(projectConfig)).files)).toEqual([mcpJson()]); + }); + + it('still writes the config while another command holds its lock, and records it on the next pull', async () => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + const lock = `${await sidecarFile()}.teamai-lock`; + await fse.ensureDir(path.dirname(lock)); + expect(await acquireLock(lock)).toBe(true); + await writeMcpYaml(withSecret); + try { + await reconcileMcpForConfig(teamConfig, claudeOnly()); + } finally { + await releaseLock(lock); + } + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect((await readResolvedMcpFiles(projectConfig)).files).toEqual({}); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(Object.keys((await readResolvedMcpFiles(projectConfig)).files)).toEqual([mcpJson()]); + }, 30_000); + + it.each([ + ['it is deleted after a pull rebuilt the lost record', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await loseManifest(); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await fse.remove(await sidecarFile()); + }], + ['an older teamai, which kept none, wrote the config and rebuilt the lost record', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await fse.remove(await sidecarFile()); + await loseManifest(); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + }], + ])('releases the line of a stale entry whose value is no longer set when %s', async (_label, arrange) => { + await arrange(); + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + // The documented limit: without the note, the stale entry looks like the member's own. + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + }); + it('records a config an older teamai wrote a resolved value to on the first pull that finds it', async () => { const { readResolvedMcpFiles, resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); await writeMcpYaml(withSecret); From 23a00dfef5756c82e01d36c62532aae920f9e007 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:34:43 +0200 Subject: [PATCH 30/85] docs(mcp): describe managed-mcp-files.json and the configs it keeps protected (#882) --- docs/designs/data-directory-layout.md | 6 ++++-- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 3 ++- skill-data/setup/references/uninstall.md | 6 ++++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 3a3ffe3a0..2d6b03f6b 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -436,6 +436,8 @@ every checkout, so that is where they live now: ├── reports-wt/ (the side-branch locks sit beside them) ├── pending-learnings/ pendingLearningsDir → /pending-learnings └── workspaces// + ├── managed-mcp.json managedMcpManifestPath, one per checkout + ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs a pull wrote a resolved ${VAR} to (#882) └── search-index.json getProjectSearchIndexPath, one per checkout /.teamai/ one per checkout: committed knowledge, knowledge-wt/ ``` @@ -652,8 +654,8 @@ the other repository, and `recall` rebuilds a missing index. `uninstall` lists, how many unpublished learnings each queue in the data home holds, set-aside ones included, so the member can publish or copy them first. -Every checkout keeps its `workspaces//` (search index, managed MCP, -resource cache) in the shared data home. A full `pull` removes those of +Every checkout keeps its `workspaces//` (search index, managed MCP and +the MCP configs it wrote a resolved value to, resource cache) in the shared data home. A full `pull` removes those of checkouts `git worktree list` no longer shows; the fast path does not list worktrees. diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 9c91b50a7..c9f65cb71 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), or one still holding a server since removed from `mcp.yaml`. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing). `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 f6b3d7c4e..843029f9b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),或仍含已从 `mcp.yaml` 删除的 server 的文件。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、仍在环境中设置的变量的值(8 个字符以上)。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 60c27c04d..b45e65cf1 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -47,7 +47,8 @@ as it was, warns, and `teamai mcp list` shows `withheld: — . ` and rotate the token), then run `teamai pull`. A pull or `teamai mcp remove` takes a line out once its file no longer holds a resolved value; `teamai uninstall` does so in -every worktree. +every worktree. A file written under a `toolPaths..mcpProject` the team +later changes or removes stays listed until it is deleted or holds no server. ## Invite a member diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 4b2c39b3d..c991bc3e7 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -57,7 +57,9 @@ and give it your team repo URL."* that is the `--agent ` form, not a full uninstall. - In a project, uninstall also takes teamai's lines out of `.git/info/exclude` (the `# [teamai:mcp-exclude:start]` block) for MCP configs it proves hold no - resolved `${VAR}` value. For one it cannot prove clean it keeps the line and - warns, naming the file and why: have the user remove teamai's servers from that + resolved `${VAR}` value. For one it cannot prove clean (including one written + under a `toolPaths` mapping since changed, or in a nested repository's linked + worktree, that still holds servers) it keeps the line and warns, naming the + file and why: have the user remove teamai's servers from that file, then delete the line (with the last one, the block's markers). Do not delete a kept line while its file still holds a token. From 0ec4246f3fc63b28f185d6cebe1a1b3e8fa5b041 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 08:49:55 +0200 Subject: [PATCH 31/85] refactor(mcp): keep the #882 record edits off the lines #880 changes (#882) --- src/mcp-reconcile.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 86919b86e..c612a1827 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -717,12 +717,13 @@ export async function mcpConfigsNotProvenClean( : {}; // This checkout listed again under its real path is not another worktree. const foreign = cfg !== localConfig && await realRoot(cfg.projectRoot) !== ownRoot; - const cfgTargets = await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true }); + const cfgTargets: McpTarget[] = []; recordedBy.set(cfg, cfgTargets); const { files: ledger } = await readResolvedMcpFiles(cfg); - for (const target of cfgTargets) { + for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); + cfgTargets.push(target); const records = manifest[managedMcpManifestKey(target.tool, true)]; const recorded = Array.isArray(records); const owned = recorded ? records : []; @@ -1010,12 +1011,12 @@ async function reconcileTargets( // mcp.yaml get cleaned out of the tools we previously injected them into. const nothingOwned = Object.values(manifest).every((r) => r.length === 0); if (teamDefs.length === 0 && nothingOwned) return { changes, wrote }; - - const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); // The files an earlier pull recorded, and each record this run rebuilds after it was lost (#882). - const listed = projectScope && !options.dryRun ? new Set(Object.keys((await readResolvedMcpFiles(localConfig)).files)) : new Set(); + const listed = localConfig.scope === 'project' && !options.dryRun ? new Set(Object.keys((await readResolvedMcpFiles(localConfig)).files)) : new Set(); const rebuilt: Array<{ target: McpTarget; recorded: string[] }> = []; + const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); + for (const target of targets) { // Same enabledAgents / disabledAgents gate as the other resource syncs. The // manifest entry is left as is: an excluded tool is skipped, not cleaned, From 53c4216034e260ed987461e9aeba0fa6cceb8c96 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 09:37:23 +0200 Subject: [PATCH 32/85] fix(mcp): protect a config an older teamai wrote under a mapping an earlier teamai.yaml made (#882) A teamai from before managed-mcp-files.json kept no record of the path it wrote a resolved value to. Once the team changed that toolPaths mapping, no pull visited the file. The first pull on this version now reads every mcpProject path the team repo's history of teamai.yaml mapped, once per worktree: a file under the project root that no current mapping or record reaches, and that holds a resolved value, is listed in .git/info/exclude and recorded. A git error leaves the read for the next pull; a shallow clone reads the history it has. --- src/__tests__/mcp-reconcile.test.ts | 124 +++++++++++++++++++++++ src/__tests__/mcp-resolved-files.test.ts | 9 ++ src/mcp-reconcile.ts | 85 ++++++++++++++-- src/mcp-resolved-files.ts | 36 +++++-- 4 files changed, 236 insertions(+), 18 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index e822de143..ca47a1116 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1132,6 +1132,130 @@ servers: }); }); + describe('a config an older teamai wrote under a mapping an earlier teamai.yaml made', () => { + const custom = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }; + const customFile = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); + const commitTeamYaml = async (toolPaths: object, cwd = repoPath): Promise => { + // JSON is YAML. + await fse.writeFile(path.join(cwd, 'teamai.yaml'), JSON.stringify({ team: 't', toolPaths })); + git(cwd, 'add', '-A'); + git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'toolPaths'); + }; + const ledger = async (cfg = projectConfig): Promise<{ files: Record; earlierMappingsRead?: true }> => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + return readResolvedMcpFiles(cfg); + }; + // What a teamai from before managed-mcp-files.json leaves: no record of the path, nothing in the exclude. + const asOlderTeamai = async (): Promise => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record>>; + await fse.writeJson(manifestFile, Object.fromEntries(Object.entries(manifest).map(([key, records]) => + [key, records.map(({ name, hash }) => ({ name, hash }))]))); + }; + + beforeEach(async () => { + await writeMcpYaml(withSecret); + git(repoPath, 'init', '-q'); + await commitTeamYaml(custom); + await reconcileMcpForConfig({ ...teamConfig, toolPaths: custom } as TeamaiConfig, projectConfig); + expect(await fse.readFile(customFile(), 'utf-8')).toContain('super-secret-value'); + await commitTeamYaml(TOOL_PATHS); + await asOlderTeamai(); + }); + + it('is listed and recorded by the first pull on this version', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/team-mcp\.json/); + expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'] }); + }); + + it('keeps its line on the pulls after, from the record', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + }); + + it('reads the team repo\'s history once per worktree', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + expect((await ledger()).earlierMappingsRead).toBe(true); + const { updateResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + await updateResolvedMcpFiles(projectConfig, (files) => delete files[customFile()]); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + }); + + it('leaves a file it does not find a resolved value in alone', async () => { + await fse.writeJson(customFile(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + expect(Object.keys((await ledger()).files)).not.toContain(customFile()); + }); + + it('leaves a mapped path outside the project root alone', async () => { + const outside = path.join(tmpDir, 'outside', 'mcp.json'); + await fse.outputFile(outside, await fse.readFile(customFile(), 'utf-8')); + await commitTeamYaml({ ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '../outside/mcp.json' } }); + await commitTeamYaml(TOOL_PATHS); + await fse.remove(customFile()); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(Object.keys((await ledger()).files)).not.toContain(outside); + expect(await excludeOf(projectRoot)).not.toMatch(/outside/); + }); + + it.each([ + ['the team repo is a shallow clone without that revision', true, async (): Promise => { + const shallow = path.join(tmpDir, 'team-shallow'); + execFileSync('git', ['clone', '-q', '--depth', '1', `file://${repoPath}`, shallow]); + return { ...projectConfig, repo: { ...projectConfig.repo, localPath: shallow } } as LocalConfig; + }], + ['teamai.yaml was never committed', true, async (): Promise => { + await fse.remove(path.join(repoPath, '.git')); + git(repoPath, 'init', '-q'); + git(repoPath, 'add', 'mcp'); + git(repoPath, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'mcp'); + return projectConfig; + }], + ['the team repo has no commits', false, async (): Promise => { + await fse.remove(path.join(repoPath, '.git')); + git(repoPath, 'init', '-q'); + return projectConfig; + }], + ['the team repo is not a git repository', false, async (): Promise => { + await fse.remove(path.join(repoPath, '.git')); + return projectConfig; + }], + ['git fails reading it', false, async (): Promise => { + await fse.emptyDir(path.join(repoPath, '.git', 'objects')); + return projectConfig; + }], + ])('protects as before when %s', async (_label, read, arrange) => { + const cfg = await arrange(); + + await expect(reconcileMcpForConfig(teamConfig, cfg)).resolves.toBeDefined(); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + // Read as far as git could: a failure is tried again on the next pull. + expect((await ledger(cfg)).earlierMappingsRead).toBe(read ? true : undefined); + }); + }); + it('adds nothing for a disabled tool\'s config whose server never held a resolved value, after its definition changed', async () => { await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); await reconcileMcpForConfig(teamConfig, projectConfig); diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts index a905f8662..aac7f0a28 100644 --- a/src/__tests__/mcp-resolved-files.test.ts +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -175,6 +175,15 @@ describe('managed-mcp-files.json', () => { expect((await readResolvedMcpFiles(cfg)).files[other]).toEqual({ tools: ['claude'] }); }); + it('remembers that the files earlier teamai.yaml mappings reach were read, through later settles', async () => { + expect((await readResolvedMcpFiles(cfg)).earlierMappingsRead).toBeUndefined(); + + await settleResolvedMcpFiles(cfg, [], { earlierMappingsRead: true }); + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'claude', state: { kind: 'missing' }, holding: false, owned: [] }]); + + expect(await readResolvedMcpFiles(cfg)).toEqual({ version: 1, files: { [cursor()]: expect.anything() }, earlierMappingsRead: true }); + }); + it('leaves a file it does not list alone when nothing holds a value there', async () => { const other = path.join(tmp, 'project', '.mcp.json'); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index c612a1827..d00b3700d 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import fse from 'fs-extra'; +import YAML from 'yaml'; import type { LocalConfig, TeamaiConfig, @@ -56,7 +57,7 @@ import { resolvedVariableIn, type GitExclusion, } from './mcp-git-exclude.js'; -import { listWorktrees } from './utils/git.js'; +import { createGit, getFileContentAtRev, listWorktrees } from './utils/git.js'; import { readResolvedMcpFiles, recordUnverifiedMcpServers, @@ -594,6 +595,58 @@ export async function recordedMcpTargets(cfg: LocalConfig, known: McpTarget[]): return recorded; } +/** + * The files earlier revisions of the team's teamai.yaml mapped a tool's + * project MCP config to (`toolPaths..mcpProject`) that exist under the + * project root, and that no target in `known` and no file `cfg`'s worktree + * recorded is (#882): a teamai from before managed-mcp-files.json may have + * written a resolved value there, under a mapping the team changed before + * this member's first pull on a teamai that records one. Read from the team + * repo's history of teamai.yaml, as far as the clone has it (a shallow clone + * has less). Null when git cannot read it: not a repository, no commits, a + * git error. + */ +export async function earlierMappedMcpTargets(cfg: LocalConfig, known: McpTarget[]): Promise { + const { projectRoot } = cfg; + if (!projectRoot) return []; + const repoPath = cfg.repo.localPath; + let revisions: string[]; + try { + revisions = (await createGit(repoPath).raw(['log', '--format=%H', 'HEAD', '--', 'teamai.yaml'])).split('\n').filter(Boolean); + } catch (e) { + log.debug(`Could not read the history of teamai.yaml in ${repoPath}: ${e instanceof Error ? e.message : String(e)}. The next pull tries again.`); + return null; + } + const root = await realFilePath(projectRoot); + const reached = new Set(await Promise.all( + [...known.map((target) => target.file), ...Object.keys((await readResolvedMcpFiles(cfg)).files)].map(realFilePath), + )); + const found = new Map(); + for (const revision of revisions) { + let toolPaths: unknown; + try { + toolPaths = (YAML.parse((await getFileContentAtRev(repoPath, revision, './teamai.yaml'))?.toString() ?? '') as { toolPaths?: unknown } | null)?.toolPaths; + } catch { + continue; + } + if (typeof toolPaths !== 'object' || toolPaths === null) continue; + for (const [tool, paths] of Object.entries(toolPaths)) { + const rel: unknown = typeof paths === 'object' && paths !== null ? (paths as { mcpProject?: unknown }).mcpProject : undefined; + const format = detectMcpFormat(tool); + if (typeof rel !== 'string' || !format) continue; + const file = path.resolve(resolveToolBaseDir(tool, cfg), rel); + const key = `${tool}\0${file}`; + if (found.has(key)) continue; + const real = await realFilePath(file); + const inside = path.relative(root, real); + if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue; + if (reached.has(real) || !await pathExists(file)) continue; + found.set(key, { tool, format, file, projectScope: true }); + } + } + return [...found.values()]; +} + /** What one file, read in the format of each of `targets` (all for that file), holds. */ async function mcpFileState(targets: McpTarget[]): Promise { const servers = new Set(); @@ -645,8 +698,12 @@ async function observeMcpConfigs( } /** `settleResolvedMcpFiles`, which only ever brings the record closer to the disk: a failure waits for the next pull. */ -async function settleRecordedMcpConfigs(localConfig: LocalConfig, observations: McpFileObservation[]): Promise { - const result = await settleResolvedMcpFiles(localConfig, observations).catch((e: unknown) => e instanceof Error ? e.message : String(e)); +async function settleRecordedMcpConfigs( + localConfig: LocalConfig, + observations: McpFileObservation[], + options?: { earlierMappingsRead?: boolean }, +): Promise { + const result = await settleResolvedMcpFiles(localConfig, observations, options).catch((e: unknown) => e instanceof Error ? e.message : String(e)); if (result !== 'written' && result !== 'unchanged') { log.debug(`Did not update managed-mcp-files.json: ${result === 'locked' ? 'another teamai command held it past the wait' : result}. The next pull tries again.`); } @@ -862,12 +919,22 @@ async function protectProjectMcpConfigs( const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const vars = await buildVarTable(localConfig); const ctx = once(() => buildDesiredMcpContext(teamConfig, localConfig)); + const mapped = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); // Tried before its write this run, and reported there. - const targets = (await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) - .filter((target) => exclusions.get(target.file)?.kind !== 'failed'); - const { files: ledger } = await readResolvedMcpFiles(localConfig); - const observations = await observeMcpConfigs(localConfig, targets, manifest, async (target, owned) => - await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null); + const targets = mapped.filter((target) => exclusions.get(target.file)?.kind !== 'failed'); + const { files: ledger, earlierMappingsRead } = await readResolvedMcpFiles(localConfig); + const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => + await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null; + const observations = await observeMcpConfigs(localConfig, targets, manifest, holds); + // Once per worktree, what a teamai that kept no record of paths wrote under a mapping the team has since changed. + const earlier = earlierMappingsRead ? [] : await earlierMappedMcpTargets(localConfig, mapped).catch((e: unknown) => { + log.debug(`Did not read the MCP configs earlier toolPaths mappings reach: ${e instanceof Error ? e.message : String(e)}`); + return null; + }); + for (const target of earlier ?? []) { + const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; + observations.push({ file: target.file, tool: target.tool, state: await mcpFileState([target]), holding: await holds(target, owned), owned: [] }); + } const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); // Also a file listed before its write: a concurrent uninstall may have taken its line out since. @@ -880,7 +947,7 @@ async function protectProjectMcpConfigs( }); await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before); // After the release, which reads the files recorded before this run; also lists one an older teamai wrote. - await settleRecordedMcpConfigs(localConfig, observations); + await settleRecordedMcpConfigs(localConfig, observations, { earlierMappingsRead: !earlierMappingsRead && earlier !== null }); } /** diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts index ee4d68143..6ab5bbd42 100644 --- a/src/mcp-resolved-files.ts +++ b/src/mcp-resolved-files.ts @@ -12,9 +12,11 @@ import { updateFileLocked, type ExcludeUpdate } from './mcp-git-exclude.js'; // tell teamai's stale entries from the member's own (#882). This file, next // to the worktree's managed-mcp.json, remembers both: each project MCP config // a pull wrote a resolved value to, by absolute path, with the tools it wrote -// it for, and the servers it found there when it rebuilt a lost record. -// Nothing depends on it to keep a line: missing or unreadable, it reads as -// empty and the rules without it apply. +// it for, and the servers it found there when it rebuilt a lost record. It +// also says whether a pull has read the files earlier revisions of the team's +// teamai.yaml mapped, which a teamai from before this file wrote to without +// recording them. Nothing depends on it to keep a line: missing or unreadable, +// it reads as empty and the rules without it apply. export interface ResolvedMcpFile { /** The tools whose MCP format the file was written in. */ @@ -27,6 +29,8 @@ export interface ResolvedMcpFiles { version: 1; /** Keyed by the file's absolute path. */ files: Record; + /** A pull has read the project MCP configs earlier revisions of teamai.yaml mapped. */ + earlierMappingsRead?: true; } /** What a command found in a project MCP config, for `settleResolvedMcpFiles`. */ @@ -74,7 +78,9 @@ function parse(content: string): Sidecar { export async function readResolvedMcpFiles(cfg: LocalConfig): Promise { const file = resolvedMcpFilesPath(cfg); const content = file === null ? null : await readFileSafe(file).catch(() => null); - return { version: 1, files: content === null ? {} : parse(content).files }; + if (content === null) return { version: 1, files: {} }; + const sidecar = parse(content); + return { version: 1, files: sidecar.files, ...sidecar.earlierMappingsRead === true ? { earlierMappingsRead: true } : {} }; } /** @@ -82,12 +88,16 @@ export async function readResolvedMcpFiles(cfg: LocalConfig): Promise) => boolean): Promise { +export function updateResolvedMcpFiles(cfg: LocalConfig, edit: (files: Record) => boolean): Promise { + return updateSidecar(cfg, (sidecar) => edit(sidecar.files)); +} + +async function updateSidecar(cfg: LocalConfig, edit: (sidecar: Sidecar) => boolean): Promise { const file = resolvedMcpFilesPath(cfg); if (file === null) return 'unchanged'; return updateFileLocked(file, (content) => { const sidecar = parse(content); - return edit(sidecar.files) ? `${JSON.stringify(sidecar, null, 2)}\n` : null; + return edit(sidecar) ? `${JSON.stringify(sidecar, null, 2)}\n` : null; }, { mode: 0o600 }); } @@ -128,10 +138,18 @@ export function recordUnverifiedMcpServers(cfg: LocalConfig, found: Array<{ file * gone or holds no server, record one holding a resolved value it did not * list (written by an older teamai), and drop a noted server that left its * file or that teamai owns again. A file that does not parse stays as it is. + * `earlierMappingsRead`: the observations cover the files earlier revisions + * of teamai.yaml mapped, which later pulls need not read again. */ -export function settleResolvedMcpFiles(cfg: LocalConfig, observations: McpFileObservation[]): Promise { - return updateResolvedMcpFiles(cfg, (files) => { - let changed = false; +export function settleResolvedMcpFiles( + cfg: LocalConfig, + observations: McpFileObservation[], + options: { earlierMappingsRead?: boolean } = {}, +): Promise { + return updateSidecar(cfg, (sidecar) => { + const { files } = sidecar; + let changed = options.earlierMappingsRead === true && sidecar.earlierMappingsRead !== true; + if (changed) sidecar.earlierMappingsRead = true; for (const { file, tool, state, holding, owned } of observations) { const entry = files[file]; if (state.kind === 'missing' || (state.kind === 'parsed' && state.servers.length === 0)) { From a466fd5055d2d79bb5416ed37c682fcb784b03be Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 09:37:31 +0200 Subject: [PATCH 33/85] fix(mcp): keep a rebuilt record from persisting without its note of the file's other servers (#882) When a pull rebuilt a lost managed-mcp.json and could not note the other servers in the file (managed-mcp-files.json locked, an I/O error), it still wrote the rebuilt record, so no later pull knew the record was rebuilt and a stale server's line could go. The same manifest write now marks those records unnoted: the file counts as having no record, so it keeps its line while it holds a server, and the next pull notes them and clears the mark. --- src/__tests__/mcp-reconcile.test.ts | 34 ++++++++++++++++ src/mcp-reconcile.ts | 63 +++++++++++++++++------------ src/types.ts | 6 +++ 3 files changed, 78 insertions(+), 25 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index ca47a1116..c92ee9beb 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1381,6 +1381,40 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('and every pull after one that rewrote the manifest it had lost while another command held managed-mcp-files.json', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.remove(managedMcpManifestPath(getDataHome(projectConfig), projectRoot)); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + const lock = `${resolvedMcpFilesPath(projectConfig)}.teamai-lock`; + expect(await acquireLock(lock)).toBe(true); + try { + await reconcileMcpForConfig(teamConfig, claudeOnly()); + expect(await fse.readJson(mcpJson())).toMatchObject({ mcpServers: { open: expect.anything() } }); + // Still held: the note is still missing, and so the line stays. + await reconcileMcpForConfig(teamConfig, claudeOnly()); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + } finally { + await releaseLock(lock); + } + + await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + + // Noted at last: once the member takes the stale server out, the line goes. + const doc = await fse.readJson(mcpJson()) as { mcpServers: Record }; + delete doc.mcpServers['with-secret']; + await fse.writeJson(mcpJson(), doc); + await reconcileMcpForConfig(teamConfig, claudeOnly()); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }, 30_000); + describe.each([ ['empty', ''], ['truncated', '{ "claude:project": [ { "name": "with-sec'], diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index d00b3700d..9340d2e36 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -744,7 +744,8 @@ async function readProjectMcpManifest(cfg: LocalConfig, projectRoot: string): Pr * and cleanup left (their definition may have left mcp.yaml), and none of the * values of the variables set in this environment. Anything else (no tool reads * it, it does not parse, the team's servers cannot be read, the manifest is - * lost, empty, does not parse or has no record for the tool) is not: a server + * lost, empty, does not parse, has no record for the tool, or a record rebuilt + * without noting the file's other servers in managed-mcp-files.json) is not: a server * teamai wrote, since dropped from mcp.yaml, with a value no longer set, looks * like the member's own. * `before` is `localConfig`'s manifest as it stood before a reconcile rewrote it. @@ -782,8 +783,9 @@ export async function mcpConfigsNotProvenClean( const key = path.join(dir, path.basename(target.file)); cfgTargets.push(target); const records = manifest[managedMcpManifestKey(target.tool, true)]; - const recorded = Array.isArray(records); - const owned = recorded ? records : []; + const owned = Array.isArray(records) ? records : []; + // A rebuilt record whose file's other servers could not be noted says nothing of them yet. + const recorded = Array.isArray(records) && !records.some((record) => record.unnoted); // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. const seen = targets.get(key); targets.set(key, { @@ -840,7 +842,7 @@ export async function mcpConfigsNotProvenClean( : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` : await resolvedValueEvidence(known.target, teamDefs, known, vars, ctx).then((e) => e && `it holds ${e}`) ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] - ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse or has no entry for it'); + ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse, has no entry for it or was rebuilt without noting its other servers'); if (why) held.set(file, why); } } @@ -1080,7 +1082,7 @@ async function reconcileTargets( if (teamDefs.length === 0 && nothingOwned) return { changes, wrote }; // The files an earlier pull recorded, and each record this run rebuilds after it was lost (#882). const listed = localConfig.scope === 'project' && !options.dryRun ? new Set(Object.keys((await readResolvedMcpFiles(localConfig)).files)) : new Set(); - const rebuilt: Array<{ target: McpTarget; recorded: string[] }> = []; + const rebuilt: Array<{ target: McpTarget; records: ManagedMcpRecord[] }> = []; const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); @@ -1124,19 +1126,26 @@ async function reconcileTargets( // Whether each entry holds a resolved value: once its definition stops // needing one, what this pull wrote still does (#882). - if (target.projectScope) for (const record of nextRecords) record.resolved ??= carriesResolvedValue(target, teamDefs, [record.name]); - if (listed.has(target.file) && manifest[manifestKey] === undefined && nextRecords.length > 0) { - rebuilt.push({ target, recorded: nextRecords.map((record) => record.name) }); + if (target.projectScope) { + for (const record of nextRecords) { + record.resolved ??= carriesResolvedValue(target, teamDefs, [record.name]); + delete record.unnoted; + } } + // Rebuilt this run, or by one that could not note what else was in the file. + const unnoted = manifest[manifestKey] === undefined || manifest[manifestKey].some((record) => record.unnoted); + if (listed.has(target.file) && unnoted && nextRecords.length > 0) rebuilt.push({ target, records: nextRecords }); // An emptied project record stays: it says teamai owns nothing left in that // file, which a lost record cannot, and so lets its exclude line go (#882). if (nextRecords.length > 0 || (target.projectScope && manifest[manifestKey] !== undefined)) manifest[manifestKey] = nextRecords; else delete manifest[manifestKey]; } - if (!options.dryRun && wrote) { - // Before the manifest: once it is written, nothing says the record was rebuilt. - await noteUnverifiedMcpServers(localConfig, rebuilt); + if (!options.dryRun && (wrote || rebuilt.length > 0)) { + // Before the manifest: once it is written, only a record marked unnoted says it was rebuilt. + for (const records of await noteUnverifiedMcpServers(localConfig, rebuilt)) { + for (const record of records) record.unnoted = true; + } await writeJsonAtomic(manifestPath, manifest); } return { changes, wrote }; @@ -1145,26 +1154,30 @@ async function reconcileTargets( /** * Note, for each file whose lost record this run rebuilt, the servers in it * the new record does not claim: a stale entry teamai wrote looks like the - * member's own once its value is no longer set (#882). A failure does not - * stop the manifest write, which the next pull needs to own what this one wrote. + * member's own once its value is no longer set (#882). Returns the records of + * each file it could not note them for: the manifest write marks them + * unnoted, so the file keeps its line and the next pull tries again, and + * still owns what this one wrote. */ -async function noteUnverifiedMcpServers(localConfig: LocalConfig, rebuilt: Array<{ target: McpTarget; recorded: string[] }>): Promise { - if (rebuilt.length === 0) return; - const found: Array<{ file: string; names: string[] }> = []; - for (const { target, recorded } of rebuilt) { +async function noteUnverifiedMcpServers( + localConfig: LocalConfig, + rebuilt: Array<{ target: McpTarget; records: ManagedMcpRecord[] }>, +): Promise { + const found: Array<{ file: string; names: string[]; records: ManagedMcpRecord[] }> = []; + for (const { target, records } of rebuilt) { const installed = await installedMcpEntries(target); - found.push({ file: target.file, names: [...installed?.keys() ?? []].filter((name) => !recorded.includes(name)) }); + const names = [...installed?.keys() ?? []].filter((name) => !records.some((record) => record.name === name)); + if (names.length > 0) found.push({ file: target.file, names, records }); } + if (found.length === 0) return []; const result = await recordUnverifiedMcpServers(localConfig, found).catch((e: unknown) => e instanceof Error ? e.message : String(e)); - if (result === 'written' || result === 'unchanged') return; - const files = found.filter((f) => f.names.length > 0).map((f) => f.file); - if (files.length === 0) return; - log.warn( - `Could not note the MCP servers teamai found in ${files.join(', ')} while rebuilding its lost record of them: ` + if (result === 'written' || result === 'unchanged') return []; + log.debug( + `Did not note the MCP servers teamai found in ${found.map((f) => f.file).join(', ')} while rebuilding its lost record of them: ` + `${result === 'locked' ? 'another teamai command held managed-mcp-files.json past the wait' : result}. ` - + 'A later pull may take the file out of .git/info/exclude while a server an earlier pull wrote there is still in it: ' - + 'remove the servers you did not add yourself, and do not commit the file meanwhile.', + + 'They keep their .git/info/exclude lines while they hold MCP servers; the next pull tries again.', ); + return found.map((f) => f.records); } /** diff --git a/src/types.ts b/src/types.ts index dcaec2a84..2805c6aeb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -898,6 +898,12 @@ export interface ManagedMcpRecord { * (#882). Absent in records an older teamai wrote. */ resolved?: boolean; + /** + * Project scope: this record was rebuilt after it was lost, and the other + * servers in its file could not be noted in managed-mcp-files.json (#882). + * Until a pull notes them, the file counts as having no record. + */ + unnoted?: true; } /** ~/.teamai/managed-mcp.json — team MCP servers injected per tool+scope key. */ From a933c22cf52788588b18b78df18bb5b48a48d3e2 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 09:37:44 +0200 Subject: [PATCH 34/85] fix(mcp): take back a managed-mcp-files.json record for a config the pull then did not write (#882) A pull records a config before writing a resolved value to it. When the write failed or did not happen (the file does not parse), the record stayed, and once the mapping changed a config of the member's own at that path was kept excluded while it held any server. The pull now takes back a record it added for a file it did not write, as it does the file's exclude line; the settle after records it again if the file holds a resolved value anyway. --- src/__tests__/mcp-reconcile.test.ts | 35 ++++++++++++++++++++++++ src/__tests__/mcp-resolved-files.test.ts | 10 +++++++ src/mcp-reconcile.ts | 26 ++++++++++++++++-- src/mcp-resolved-files.ts | 16 +++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index c92ee9beb..8ff1c2148 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1738,6 +1738,41 @@ servers: expect((await fse.stat(resolvedMcpFilesPath(projectConfig) ?? '')).mode & 0o777).toBe(0o600); }); + describe('forgets a config it recorded before a write that did not happen', () => { + const custom = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }; + const customFile = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); + const mine = { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }; + + afterEach(async () => { + await fse.chmod(path.join(projectRoot, '.cursor'), 0o755); + }); + + // Root writes into a read-only directory. + it.skipIf(process.getuid?.() === 0).each([ + ['its write fails', async () => { + await fse.writeJson(customFile(), mine); + await fse.chmod(path.join(projectRoot, '.cursor'), 0o555); + await expect(reconcileMcpForConfig({ ...teamConfig, toolPaths: custom } as TeamaiConfig, projectConfig)).rejects.toThrow(); + await fse.chmod(path.join(projectRoot, '.cursor'), 0o755); + }], + ['it does not parse', async () => { + await fse.writeFile(customFile(), '{ "mcpServers": '); + await reconcileMcpForConfig({ ...teamConfig, toolPaths: custom } as TeamaiConfig, projectConfig); + await fse.writeJson(customFile(), mine); + }], + ])('so a config of the member\'s own there is not kept listed once the mapping changes, when %s', async (_label, arrange) => { + await writeMcpYaml(withSecret); + await arrange(); + expect(await fse.readJson(customFile())).toEqual(mine); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + expect(Object.keys((await readResolvedMcpFiles(projectConfig)).files)).not.toContain(customFile()); + }); + }); + describe('without a usable managed-mcp-files.json, as before it existed', () => { const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); const claudeOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts index aac7f0a28..4e47f85ec 100644 --- a/src/__tests__/mcp-resolved-files.test.ts +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -13,6 +13,7 @@ import { resolvedMcpFilesPath, settleResolvedMcpFiles, trackResolvedMcpFiles, + untrackResolvedMcpFiles, } from '../mcp-resolved-files.js'; import { acquireLock, releaseLock } from '../update.js'; import type { LocalConfig } from '../types.js'; @@ -125,6 +126,15 @@ describe('managed-mcp-files.json', () => { expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [cursor()]: { tools: ['cursor'], unverified: ['jira'] } }); }); + it('takes back only the tool a record was added for, and the file with its last tool', async () => { + await trackResolvedMcpFiles(cfg, [{ tool: 'claude', file: custom() }, { tool: 'codebuddy', file: custom() }, { tool: 'cursor', file: cursor() }]); + + expect(await untrackResolvedMcpFiles(cfg, [{ tool: 'codebuddy', file: custom() }, { tool: 'cursor', file: cursor() }])).toBe('written'); + + expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [custom()]: { tools: ['claude'] } }); + expect(await untrackResolvedMcpFiles(cfg, [{ tool: 'cursor', file: cursor() }])).toBe('unchanged'); + }); + it('writes nothing to note when a file lists no servers', async () => { await trackResolvedMcpFiles(cfg, [{ tool: 'cursor', file: cursor() }]); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 9340d2e36..655eace69 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -63,6 +63,7 @@ import { recordUnverifiedMcpServers, settleResolvedMcpFiles, trackResolvedMcpFiles, + untrackResolvedMcpFiles, type McpFileObservation, } from './mcp-resolved-files.js'; @@ -871,12 +872,17 @@ export async function reconcileMcpForConfig( const exclusions = new Map(); // The project configs this run wrote: a line it added for one stays, whatever fails after. const written = new Set(); + // The project configs managed-mcp-files.json first recorded this run, before their write. + const recorded: McpTarget[] = []; const protect = !options.removeAll && !options.dryRun; // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. const before = protect && localConfig.projectRoot ? await readProjectMcpManifest(localConfig, localConfig.projectRoot) : undefined; try { - return await reconcileTargets(teamConfig, localConfig, options, exclusions, written); + return await reconcileTargets(teamConfig, localConfig, options, exclusions, written, recorded); } finally { + // A record this run added for a file it then did not write goes, as its exclude line does. The settle + // below records the file again if it holds a resolved value all the same (an earlier pull wrote it). + await forgetUnwrittenMcpConfigs(localConfig, recorded.filter((target) => !written.has(target.file))); // Also after a failed write: what earlier pulls wrote is on disk either way. if (protect) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions, written, before); } @@ -1029,6 +1035,7 @@ async function reconcileTargets( options: McpReconcileOptions, exclusions: Map, written: Set, + recorded: McpTarget[], ): Promise { const changes: McpChange[] = []; let wrote = false; @@ -1115,7 +1122,10 @@ async function reconcileTargets( continue; } // Recorded before the write, so a later change to toolPaths still finds the file. - if (!options.dryRun) await recordResolvedMcpFile(localConfig, target); + if (!options.dryRun) { + await recordResolvedMcpFile(localConfig, target); + if (!listed.has(target.file)) recorded.push(target); + } } const wroteTarget = target.format === 'codex' @@ -1192,6 +1202,18 @@ async function recordResolvedMcpFile(localConfig: LocalConfig, target: McpTarget } } +/** + * `untrackResolvedMcpFiles`. A failure leaves the record, and the file its + * line while it holds a server once no mapping reaches it. + */ +async function forgetUnwrittenMcpConfigs(localConfig: LocalConfig, targets: McpTarget[]): Promise { + if (targets.length === 0) return; + const result = await untrackResolvedMcpFiles(localConfig, targets).catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result !== 'written' && result !== 'unchanged') { + log.debug(`Did not take ${targets.map((t) => t.file).join(', ')} back out of managed-mcp-files.json: ${result === 'locked' ? 'another teamai command held it past the wait' : result}.`); + } +} + // ─── Appliers ──────────────────────────────────────────────── async function applyJson( diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts index 6ab5bbd42..9562b9f6f 100644 --- a/src/mcp-resolved-files.ts +++ b/src/mcp-resolved-files.ts @@ -115,6 +115,22 @@ export function trackResolvedMcpFiles(cfg: LocalConfig, targets: Array<{ tool: s }); } +/** Take back what `trackResolvedMcpFiles` recorded for a file it was not written to after all. */ +export function untrackResolvedMcpFiles(cfg: LocalConfig, targets: Array<{ tool: string; file: string }>): Promise { + return updateResolvedMcpFiles(cfg, (files) => { + let changed = false; + for (const { tool, file } of targets) { + const entry = files[file]; + if (!entry?.tools.includes(tool)) continue; + const tools = entry.tools.filter((t) => t !== tool); + if (tools.length > 0) files[file] = { ...entry, tools }; + else delete files[file]; + changed = true; + } + return changed; + }); +} + /** * Note `names`, servers found in a file whose lost record teamai rebuilt, as * possibly teamai's: only for a file already recorded as holding a resolved value. From df98441f624a010c455c911a0eba680a6ed2f819 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 09:37:44 +0200 Subject: [PATCH 35/85] docs(mcp): describe the teamai.yaml history read, the unnoted rebuilt record and the record a failed write takes back (#882) --- docs/designs/data-directory-layout.md | 3 ++- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 4 +++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 2d6b03f6b..ad2c819c2 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -437,7 +437,8 @@ every checkout, so that is where they live now: ├── pending-learnings/ pendingLearningsDir → /pending-learnings └── workspaces// ├── managed-mcp.json managedMcpManifestPath, one per checkout - ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs a pull wrote a resolved ${VAR} to (#882) + ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs a pull wrote a resolved ${VAR} to, and whether + │ the paths earlier teamai.yaml revisions mapped were read (#882) └── search-index.json getProjectSearchIndexPath, one per checkout /.teamai/ one per checkout: committed knowledge, knowledge-wt/ ``` diff --git a/docs/usage-guide.md b/docs/usage-guide.md index c9f65cb71..7f726b376 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml` once, as far as the clone has it, and inside the project only), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 843029f9b..c071b68e4 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以克隆中现有的历史为限,且只看项目内的路径),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index b45e65cf1..cd8797467 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -48,7 +48,9 @@ Apply the fix it names (a tracked file: `git rm --cached ` and rotate the token), then run `teamai pull`. A pull or `teamai mcp remove` takes a line out once its file no longer holds a resolved value; `teamai uninstall` does so in every worktree. A file written under a `toolPaths..mcpProject` the team -later changes or removes stays listed until it is deleted or holds no server. +later changes or removes stays listed until it is deleted or holds no server; +for one an older teamai wrote, the first pull finds the path in the team repo's +history of `teamai.yaml`. ## Invite a member From ecb300942c95230ae65161e3599f8928ca348a7c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 09:51:49 +0200 Subject: [PATCH 36/85] refactor(mcp): keep the r3 edits off the lines #880 changes (#882) --- src/__tests__/mcp-reconcile.test.ts | 52 ++++++++++++++--------------- src/mcp-reconcile.ts | 2 +- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 8ff1c2148..a239c0976 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1132,6 +1132,32 @@ servers: }); }); + it('adds nothing for a disabled tool\'s config whose server never held a resolved value, after its definition changed', async () => { + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + await reconcileMcpForConfig(teamConfig, projectConfig); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/v2\n'); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await fse.readFile(path.join(projectRoot, '.cursor', 'mcp.json'), 'utf-8')).toContain('https://example.com/open'); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); + + it('keeps listing a Codex project config after its server\'s ${VAR} became a literal and Codex was disabled', async () => { + const withCodex = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codex: { ...TOOL_PATHS.codex, mcpProject: '.codex/config.toml' } } } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.codex', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codex]\n`); + await reconcileMcpForConfig(withCodex, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.codex', 'config.toml'), 'utf-8')).toContain('super-secret-value'); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'published-literal')} tools: [codex]\n`); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(withCodex, { ...projectConfig, disabledAgents: ['codex'] } as LocalConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.codex\/config\.toml$/m); + }); + describe('a config an older teamai wrote under a mapping an earlier teamai.yaml made', () => { const custom = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }; const customFile = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); @@ -1256,32 +1282,6 @@ servers: }); }); - it('adds nothing for a disabled tool\'s config whose server never held a resolved value, after its definition changed', async () => { - await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); - await reconcileMcpForConfig(teamConfig, projectConfig); - await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/v2\n'); - - await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); - - expect(await fse.readFile(path.join(projectRoot, '.cursor', 'mcp.json'), 'utf-8')).toContain('https://example.com/open'); - expect(await excludeOf(projectRoot)).not.toContain('teamai'); - }); - - it('keeps listing a Codex project config after its server\'s ${VAR} became a literal and Codex was disabled', async () => { - const withCodex = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codex: { ...TOOL_PATHS.codex, mcpProject: '.codex/config.toml' } } } as TeamaiConfig; - await fse.ensureDir(path.join(projectRoot, '.codex', 'skills')); - await writeMcpYaml(`${withSecret} tools: [codex]\n`); - await reconcileMcpForConfig(withCodex, projectConfig); - expect(await fse.readFile(path.join(projectRoot, '.codex', 'config.toml'), 'utf-8')).toContain('super-secret-value'); - await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); - await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'published-literal')} tools: [codex]\n`); - vi.stubEnv('SECRET_TOKEN', ''); - - await reconcileMcpForConfig(withCodex, { ...projectConfig, disabledAgents: ['codex'] } as LocalConfig); - - expect(await excludeOf(projectRoot)).toMatch(/^\/\.codex\/config\.toml$/m); - }); - it('lists the config in .git/info/exclude before writing the value into it', async () => { await writeMcpYaml(withSecret); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 655eace69..b2480e075 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1,7 +1,6 @@ import crypto from 'node:crypto'; import path from 'node:path'; import fse from 'fs-extra'; -import YAML from 'yaml'; import type { LocalConfig, TeamaiConfig, @@ -20,6 +19,7 @@ import { scopedToolPaths, TeamaiConfigSchema, } from './types.js'; +import YAML from 'yaml'; import { detectMcpFormat, supportsTransport, From 16007ae2757d840d7d3245d24144511fb413cc01 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 10:19:46 +0200 Subject: [PATCH 37/85] fix(mcp): also protect a config an older teamai wrote under a built-in default it has since changed (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 2 +- src/__tests__/mcp-reconcile.test.ts | 14 +++++++++++++ src/mcp-reconcile.ts | 23 ++++++++++++++------- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7f726b376..e8e285cd0 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml` once, as far as the clone has it, and inside the project only), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, and inside the project only), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 c071b68e4..7718cd6ec 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以克隆中现有的历史为限,且只看项目内的路径),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的路径),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index cd8797467..397811601 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -50,7 +50,7 @@ once its file no longer holds a resolved value; `teamai uninstall` does so in every worktree. A file written under a `toolPaths..mcpProject` the team later changes or removes stays listed until it is deleted or holds no server; for one an older teamai wrote, the first pull finds the path in the team repo's -history of `teamai.yaml`. +history of `teamai.yaml`, or among the built-in paths teamai has since changed. ## Invite a member diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index a239c0976..cdae5e6e6 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1231,6 +1231,20 @@ servers: expect(Object.keys((await ledger()).files)).not.toContain(customFile()); }); + it('also finds one under a built-in default teamai has since changed (CodeBuddy\'s .codebuddy/mcp.json)', async () => { + const today = { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } }; + await fse.remove(path.join(repoPath, '.git')); + git(repoPath, 'init', '-q'); + await commitTeamYaml(today); + const oldDefault = path.join(projectRoot, '.codebuddy', 'mcp.json'); + await fse.outputFile(oldDefault, await fse.readFile(customFile(), 'utf-8')); + + await reconcileMcpForConfig({ ...teamConfig, toolPaths: today } as TeamaiConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.codebuddy\/mcp\.json$/m); + expect((await ledger()).files[oldDefault]).toEqual({ tools: ['codebuddy'] }); + }); + it('leaves a mapped path outside the project root alone', async () => { const outside = path.join(tmpDir, 'outside', 'mcp.json'); await fse.outputFile(outside, await fse.readFile(customFile(), 'utf-8')); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index b2480e075..97d4a04e4 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -596,13 +596,20 @@ export async function recordedMcpTargets(cfg: LocalConfig, known: McpTarget[]): return recorded; } +// Built-in mcpProject defaults an older teamai wrote to and no longer maps: +// no teamai.yaml revision names them. +const EARLIER_BUILTIN_MCP_PROJECT = { + codebuddy: { mcpProject: '.codebuddy/mcp.json' }, // before 57636a27 +}; + /** * The files earlier revisions of the team's teamai.yaml mapped a tool's * project MCP config to (`toolPaths..mcpProject`) that exist under the * project root, and that no target in `known` and no file `cfg`'s worktree * recorded is (#882): a teamai from before managed-mcp-files.json may have * written a resolved value there, under a mapping the team changed before - * this member's first pull on a teamai that records one. Read from the team + * this member's first pull on a teamai that records one, plus those under a + * built-in default teamai has since changed. Read from the team * repo's history of teamai.yaml, as far as the clone has it (a shallow clone * has less). Null when git cannot read it: not a repository, no commits, a * git error. @@ -623,12 +630,14 @@ export async function earlierMappedMcpTargets(cfg: LocalConfig, known: McpTarget [...known.map((target) => target.file), ...Object.keys((await readResolvedMcpFiles(cfg)).files)].map(realFilePath), )); const found = new Map(); - for (const revision of revisions) { - let toolPaths: unknown; - try { - toolPaths = (YAML.parse((await getFileContentAtRev(repoPath, revision, './teamai.yaml'))?.toString() ?? '') as { toolPaths?: unknown } | null)?.toolPaths; - } catch { - continue; + for (const revision of [null, ...revisions]) { + let toolPaths: unknown = EARLIER_BUILTIN_MCP_PROJECT; + if (revision !== null) { + try { + toolPaths = (YAML.parse((await getFileContentAtRev(repoPath, revision, './teamai.yaml'))?.toString() ?? '') as { toolPaths?: unknown } | null)?.toolPaths; + } catch { + continue; + } } if (typeof toolPaths !== 'object' || toolPaths === null) continue; for (const [tool, paths] of Object.entries(toolPaths)) { From c252a0e660af38bd83cb21d8de2d31faaf7c5e59 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:23:27 +0200 Subject: [PATCH 38/85] fix(mcp): judge a project MCP config under a symlinked directory where the write lands (#882) The appliers replace the file itself (tmp + rename) but follow its directories. Every git check now judges realFilePath(file), the one resolver the release keying already used: a directory linked out of any repository no longer withholds the servers on git's "not a git repository", and a tracked file there is named with both paths, with a git rm --cached that works (git refuses the path through the link). --- src/__tests__/doctor-mcp-delivery.test.ts | 45 +++++++++++ src/__tests__/mcp-git-exclude.test.ts | 93 +++++++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 64 ++++++++++++++++ src/__tests__/uninstall.test.ts | 34 +++++++++ src/doctor-delivery.ts | 6 +- src/mcp-git-exclude.ts | 50 +++++++++--- src/mcp-reconcile.ts | 9 ++- 7 files changed, 286 insertions(+), 15 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index cf6943bcf..2443b7baa 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -432,5 +432,50 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).not.toContain('not the team\'s definition'); expect(check.fix).not.toContain('pull --force'); }); + // The appliers replace the file itself but follow its directories (#886). + describe('for a config under a symlinked directory, judged where the write lands', () => { + const logical = (): string => path.join(projectRoot, 'cfg', 'mcp.json'); + + beforeEach(async () => { + teamConfig.toolPaths = { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: 'cfg/mcp.json' } }; + await fse.move(path.join(projectRoot, '.mcp.json'), path.join(projectRoot, 'config', 'mcp.json')); + await fse.symlink('config', path.join(projectRoot, 'cfg'), 'dir'); + }); + + it('fails while git tracks the file it lands in, naming both paths', async () => { + execFileSync('git', ['add', 'config/mcp.json'], { cwd: projectRoot }); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(`${path.join(await fse.realpath(projectRoot), 'config', 'mcp.json')} (where ${logical()} is written)`); + }); + + it.each([ + ['passes once git ignores the file it lands in', '/config/mcp.json\n', true], + ['still fails when git ignores only the path it is reached by', '/cfg/mcp.json\n', false], + ])('%s', async (_label, line, ok) => { + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), line); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(ok); + }); + + it('passes when the directory links outside any repository', async () => { + const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); + await fse.move(path.join(projectRoot, 'config', 'mcp.json'), path.join(outside, 'mcp.json')); + await fse.remove(path.join(projectRoot, 'cfg')); + await fse.symlink(outside, path.join(projectRoot, 'cfg'), 'dir'); + + try { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(true); + } finally { + await fse.remove(outside); + } + }); + }); }); }); diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index 706a9c65e..08ea3a8e7 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -212,4 +212,97 @@ describe('teamai block in .git/info/exclude (#882)', () => { expect(await fse.readFile(excludeFile, 'utf8')).toBe(`${MCP_EXCLUDE_START}\n/old.json\nscratch/\n`); }); + // The appliers replace the file itself (tmp + rename) but follow its directories (#886). + describe('for a file under a symlinked directory, judged where the write lands', () => { + let real: string; + const commit = (...files: string[]): void => { + execFileSync('git', ['add', ...files], { cwd: repo }); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'config'], { cwd: repo }); + }; + + beforeEach(async () => { + real = await fse.realpath(repo); + await fse.outputFile(path.join(repo, 'config', 'README.md'), 'cursor config\n'); + await fse.symlink('config', path.join(repo, '.cursor'), 'dir'); + }); + + it('fails for a file git tracks there, naming both paths and the one to untrack', async () => { + await fse.writeJson(path.join(repo, 'config', 'mcp.json'), {}); + commit('config', '.cursor'); + const file = path.join(repo, '.cursor', 'mcp.json'); + const landed = path.join(real, 'config', 'mcp.json'); + + expect(await ensureExcludedFromGit(file)).toEqual({ + kind: 'failed', + reason: `git already tracks ${landed} (where ${file} is written)`, + fix: `Run \`git rm --cached ${landed}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }); + expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); + }); + + it('lists the file it lands in, in a tracked directory', async () => { + commit('config', '.cursor'); + + expect(await ensureExcludedFromGit(path.join(repo, '.cursor', 'mcp.json'))).toEqual({ kind: 'excluded', added: true }); + await fse.writeJson(path.join(repo, 'config', 'mcp.json'), {}); + + expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/config\/mcp\.json$/m); + expect(await fse.readFile(excludeFile, 'utf8')).not.toContain('/.cursor/'); + expect(execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repo, encoding: 'utf8' })).not.toContain('config/mcp.json'); + }); + + it('lists a file whose directory does not exist yet under the one it will be created in', async () => { + expect(await ensureExcludedFromGit(path.join(repo, '.cursor', 'sub', 'mcp.json'))).toEqual({ kind: 'excluded', added: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/config\/sub\/mcp\.json$/m); + }); + + it('protects it in the repository the directory links into, not this one', async () => { + const other = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-other-repo-')); + execFileSync('git', ['init', '-q'], { cwd: other }); + await fse.ensureDir(path.join(other, 'cfg')); + await fse.symlink(path.join(other, 'cfg'), path.join(repo, '.tool'), 'dir'); + + try { + expect(await ensureExcludedFromGit(path.join(repo, '.tool', 'mcp.json'))).toEqual({ kind: 'excluded', added: true }); + expect(await fse.readFile(path.join(other, '.git', 'info', 'exclude'), 'utf8')).toMatch(/^\/cfg\/mcp\.json$/m); + expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); + } finally { + await fse.remove(other); + } + }); + + it('lists nothing and stays quiet when the directory links outside any repository', async () => { + const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); + await fse.symlink(outside, path.join(repo, '.tool'), 'dir'); + await fse.writeJson(path.join(outside, 'mcp.json'), {}); + + try { + expect(await ensureExcludedFromGit(path.join(repo, '.tool', 'mcp.json'))).toEqual({ kind: 'excluded', added: false }); + await excludeFromGit(path.join(repo, '.tool', 'mcp.json')); + expect(log.warn).not.toHaveBeenCalled(); + expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); + } finally { + await fse.remove(outside); + } + }); + + it('judges a directory that links nowhere from the closest one that exists: no write lands through it', async () => { + await fse.symlink('missing', path.join(repo, '.dangling'), 'dir'); + + expect(await ensureExcludedFromGit(path.join(repo, '.dangling', 'mcp.json'))).toEqual({ kind: 'excluded', added: true }); + expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/\.dangling\/mcp\.json$/m); + await expect(fse.ensureDir(path.join(repo, '.dangling'))).rejects.toThrow(); + }); + + it('judges a symlink at the file itself as the file: the write replaces it', async () => { + await fse.writeJson(path.join(repo, 'config', 'mcp.json'), {}); + commit('config'); + await fse.symlink(path.join('config', 'mcp.json'), path.join(repo, '.mcp.json')); + + expect(await ensureExcludedFromGit(path.join(repo, '.mcp.json'))).toEqual({ kind: 'excluded', added: true }); + expect(await fse.readFile(excludeFile, 'utf8')).toMatch(/^\/\.mcp\.json$/m); + expect(await fse.readFile(excludeFile, 'utf8')).not.toContain('/config/'); + }); + }); }); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index cdae5e6e6..898d1280a 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1730,6 +1730,70 @@ servers: expect(await fse.pathExists(resolvedMcpFilesPath(projectConfig) ?? '')).toBe(false); }); + // The appliers replace the file itself but follow its directories (#886). + describe('a config under a symlinked directory is judged where the write lands', () => { + const cursorOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['claude'] } as LocalConfig); + const landed = async (): Promise => path.join(await fse.realpath(projectRoot), 'config', 'mcp.json'); + + beforeEach(async () => { + vi.mocked(log.warn).mockClear(); + await fse.remove(path.join(projectRoot, '.cursor')); + await fse.outputFile(path.join(projectRoot, 'config', 'skills', 'README.md'), 'cursor skills\n'); + await fse.symlink('config', path.join(projectRoot, '.cursor'), 'dir'); + }); + + it('withholds the servers from a file git tracks there, naming both paths', async () => { + await fse.writeJson(path.join(projectRoot, 'config', 'mcp.json'), { mcpServers: {} }); + git(projectRoot, 'add', 'config', '.cursor'); + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'cursor config'); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, cursorOnly()); + + expect(await fse.readFile(path.join(projectRoot, 'config', 'mcp.json'), 'utf-8')).not.toContain('super-secret-value'); + const warning = vi.mocked(log.warn).mock.calls.map(([m]) => String(m)).find((m) => m.includes('git already tracks')); + expect(warning).toContain(path.join(projectRoot, '.cursor', 'mcp.json')); + expect(warning).toContain(`git rm --cached ${await landed()}\``); + }); + + it('lists the file it lands in, and releases that line once it holds no resolved value', async () => { + git(projectRoot, 'add', 'config', '.cursor'); + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'cursor config'); + await writeMcpYaml(withSecret); + + await reconcileMcpForConfig(teamConfig, cursorOnly()); + + expect(await fse.readFile(await landed(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/config\/mcp\.json$/m); + expect(await excludeOf(projectRoot)).not.toContain('/.cursor/'); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toContain('config/mcp.json'); + + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + await reconcileMcpForConfig(teamConfig, cursorOnly()); + + expect(await fse.readFile(await landed(), 'utf-8')).not.toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).not.toContain('/config/mcp.json'); + }); + + it('writes, listing nothing and warning of nothing, when the directory links outside any repository', async () => { + const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); + await fse.copy(path.join(projectRoot, 'config'), outside); + await fse.remove(path.join(projectRoot, '.cursor')); + await fse.symlink(outside, path.join(projectRoot, '.cursor'), 'dir'); + await writeMcpYaml(withSecret); + + try { + await reconcileMcpForConfig(teamConfig, cursorOnly()); + + expect(await fse.readFile(path.join(outside, 'mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(log.warn).not.toHaveBeenCalled(); + expect(await excludeOf(projectRoot)).not.toContain('/.cursor/'); + } finally { + await fse.remove(outside); + } + }); + }); + it('records each config it writes a resolved value to, by path, before writing it', async () => { const { readResolvedMcpFiles, resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); const sidecarAtWrite = new Map(); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index d02f7932f..90d0fbb39 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1072,6 +1072,40 @@ describe('uninstall', () => { expect(await fse.readJson(path.join(projectRoot, '.mcp.json'))).toEqual({ mcpServers: { mine } }); expect(await fse.readFile(excludeFile, 'utf8')).toBe(''); }); + // The appliers replace the file itself but follow its directories (#886). + describe('for a config under a symlinked directory, judged where the write lands', () => { + const landedBlock = block.replace('/.mcp.json', '/config/mcp.json'); + + async function setupLinked(servers: Record): Promise<{ excludeFile: string }> { + const { projectRoot, excludeFile, localConfig } = await setup(); + await fse.outputJson(path.join(projectRoot, 'config', 'mcp.json'), { mcpServers: servers }); + await fse.symlink('config', path.join(projectRoot, 'cfg'), 'dir'); + await fse.writeFile(excludeFile, landedBlock); + mockAutoDetectInit.mockResolvedValue({ + localConfig, + teamConfig: makeTeamConfig({ toolPaths: { claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: 'cfg/mcp.json' } } }), + }); + return { excludeFile }; + } + + it('keeps the landing path\'s line while the file there holds the token', async () => { + const { excludeFile } = await setupLinked({ jira }); + const { log } = await import('../utils/logger.js'); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(landedBlock); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept \`/config/mcp.json\` in ${await fse.realpath(excludeFile)}`)); + }); + + it('removes it once the file there holds no server', async () => { + const { excludeFile } = await setupLinked({}); + + await uninstall({ force: true }); + + expect(await fse.readFile(excludeFile, 'utf8')).toBe(''); + }); + }); }); it('project-scope uninstall keeps a nested repository\'s block while its linked worktree holds a token (#882)', async () => { diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index bcea9973a..a5ad5a223 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -528,7 +528,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise => { holding.add(file); const tracking = await gitTracking(file); - if (tracking.kind === 'would-commit') tracked.push(file); - else if (tracking.kind === 'unknown') tracked.push(`${file} (git failed: ${tracking.error})`); + if (tracking.kind === 'would-commit') tracked.push((await gitPathOf(file)).label); + else if (tracking.kind === 'unknown') tracked.push(`${(await gitPathOf(file)).label} (git failed: ${tracking.error})`); }; // Every tool's file, delivery on or off, the same files and evidence pull protects. Two tools may share one. const targets = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 49909121b..4efe7bc58 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -92,8 +92,33 @@ export type GitTracking = | { kind: 'outside-repo' } | { kind: 'unknown'; error: string }; -/** Whether git would put `file` in a commit: tracked, or untracked without an ignore rule. Read-only. */ +/** Where a write to `file` lands (see realFilePath): the path every check here judges. */ +async function landingPath(file: string): Promise { + const { realFilePath } = await import('./mcp-reconcile.js'); + return realFilePath(file); +} + +/** + * `file` as a message names it, and the path to give git for it: the one a + * write lands in, named with `file`, when a directory inside its checkout is a + * symlink (#886), where git refuses `file` ("beyond a symbolic link"). A + * symlink above the checkout (macOS /var) changes no path git uses. + */ +export async function gitPathOf(file: string): Promise<{ label: string; path: string }> { + const landed = await landingPath(file); + if (landed === file) return { label: file, path: file }; + const location = await gitExcludeFile(await existingAncestor(landed)); + const inCheckout = location ? path.relative(location.root, landed) : ''; + if (inCheckout && !inCheckout.startsWith('..') && file.endsWith(`${path.sep}${inCheckout}`)) return { label: file, path: file }; + return { label: `${landed} (where ${file} is written)`, path: landed }; +} + +/** + * Whether git would put `file` in a commit: tracked, or untracked without an + * ignore rule. Judged where a write to it lands. Read-only. + */ export async function gitTracking(file: string): Promise { + file = await landingPath(file); const dir = await existingAncestor(file); const result = await execCommand('git', ['check-ignore', '-q', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) .catch((e: unknown) => ({ code: -1, stdout: '', stderr: e instanceof Error ? e.message : String(e) })); @@ -112,6 +137,7 @@ export async function gitTracking(file: string): Promise { * answer: never read it as untracked. */ async function gitTracks(file: string): Promise<{ kind: 'tracked' } | { kind: 'untracked' } | { kind: 'unknown'; error: string }> { + file = await landingPath(file); // The file, or even its directory, may be gone from disk and still be in the index. const dir = await existingAncestor(file); const result = await execCommand('git', ['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) @@ -160,16 +186,20 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo const tracking = await gitTracking(file); if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return { kind: 'excluded', added: false }; const repair = 'Fix the repository, or add the file to its .git/info/exclude yourself, then run `teamai pull` again.'; - const tracked: GitExclusion = { - kind: 'failed', - reason: `git already tracks ${file}`, - fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + const tracked = async (): Promise => { + const named = await gitPathOf(file); + return { + kind: 'failed', + reason: `git already tracks ${named.label}`, + fix: `Run \`git rm --cached ${named.path}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + }; }; const inIndex = await gitTracks(file); - if (inIndex.kind === 'tracked') return tracked; + if (inIndex.kind === 'tracked') return tracked(); if (inIndex.kind === 'unknown') return { kind: 'failed', reason: inIndex.error, fix: repair }; - // `file` and its directory need not exist yet: git is asked from the nearest one that does. - const dir = await existingAncestor(file); + // Where the write lands. It and its directory need not exist yet: git is asked from the nearest one that does. + const landed = await landingPath(file); + const dir = await existingAncestor(landed); const location = await gitExcludeFile(dir); if (!location) { return { @@ -180,7 +210,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo } const { excludeFile } = location; // Anchored at the working tree root, glob characters escaped. - const rel = path.relative(dir, file).split(path.sep).join('/'); + const rel = path.relative(dir, landed).split(path.sep).join('/'); const pattern = `/${location.prefix}${rel}`.replace(/[\\*?[\]!#]/g, '\\$&'); const retry = `Make it writable, or add \`${pattern}\` to it yourself, then run \`teamai pull\` again.`; // A read-only exclude file is the member's choice; the atomic write would replace it all the same. @@ -216,7 +246,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo }; } if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); - return (await gitTracking(file)).kind === 'would-commit' ? tracked : { kind: 'excluded', added: result === 'written' }; + return (await gitTracking(file)).kind === 'would-commit' ? tracked() : { kind: 'excluded', added: result === 'written' }; } /** diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 97d4a04e4..9ea3a94a9 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1031,8 +1031,13 @@ async function releaseMcpGitExcludes( } } -/** `file` with the real path of its closest existing directory. */ -async function realFilePath(file: string): Promise { +/** + * Where a write to `file` lands: the real path of its closest existing + * directory, the rest appended. The appliers replace the file itself (tmp + + * rename) but follow its directories, so every check of whether git would + * commit the file judges this path (#886), and reads keep `file`. + */ +export async function realFilePath(file: string): Promise { const dir = await existingAncestor(file); const real = await fse.realpath(dir).catch(() => dir); return path.join(real, path.relative(dir, file)); From 5152b749d9295d80ca311f111d69c381492b8e65 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:23:27 +0200 Subject: [PATCH 39/85] docs(mcp): describe how a config under a symlinked directory is kept out of git (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 3 +++ skill-data/setup/references/uninstall.md | 4 +++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index e8e285cd0..62b40965e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, and inside the project only), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, and inside the project only), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 7718cd6ec..05057475a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的路径),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的路径),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 397811601..1c68c0c47 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -41,6 +41,9 @@ A server with a `${VAR}` the tool cannot expand itself gets the resolved value written into its project config (`.mcp.json`, `.cursor/mcp.json`, ...). Before that write, teamai lists the file in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block; the committed `.gitignore` is never touched. +A file under a symlinked directory is listed and checked where the write lands +(`.cursor/` linking to `config/`: `/config/mcp.json`); a symlink at the file +itself is replaced by the write. When it cannot (git already tracks the file, `.git/info` is not writable, the exclude file is held by another teamai command, or git errors), it leaves the file as it was, warns, and `teamai mcp list` shows `withheld: — . `. diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index c991bc3e7..e84b7134f 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -57,7 +57,9 @@ and give it your team repo URL."* that is the `--agent ` form, not a full uninstall. - In a project, uninstall also takes teamai's lines out of `.git/info/exclude` (the `# [teamai:mcp-exclude:start]` block) for MCP configs it proves hold no - resolved `${VAR}` value. For one it cannot prove clean (including one written + resolved `${VAR}` value. A line names the path a write lands in: for a config + under a symlinked directory, the link's target (`/config/mcp.json` for + `.cursor/` linking to `config/`). For one it cannot prove clean (including one written under a `toolPaths` mapping since changed, or in a nested repository's linked worktree, that still holds servers) it keeps the line and warns, naming the file and why: have the user remove teamai's servers from that From d19a6d6de18090f1c1911f517c2510af88dd027f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:35:29 +0200 Subject: [PATCH 40/85] refactor(mcp): keep realFilePath next to existingAncestor, without an import cycle (#882) --- src/mcp-git-exclude.ts | 26 +++++++++++++++++--------- src/mcp-reconcile.ts | 14 +------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 4efe7bc58..971275833 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import fse from 'fs-extra'; import type { McpServerDef } from './types.js'; @@ -82,6 +83,18 @@ export async function existingAncestor(file: string): Promise { return dir; } +/** + * Where a write to `file` lands: the real path of its closest existing + * directory, the rest appended. The appliers replace the file itself (tmp + + * rename) but follow its directories, so every check of whether git would + * commit the file judges this path (#886), and reads keep `file`. + */ +export async function realFilePath(file: string): Promise { + const dir = await existingAncestor(file); + const real = await fs.promises.realpath(dir).catch(() => dir); + return path.join(real, path.relative(dir, file)); +} + /** * Whether git would put a file in a commit. `unknown` is a repository git could * not answer for (unsafe ownership, a bad config): never read it as safe. @@ -92,11 +105,6 @@ export type GitTracking = | { kind: 'outside-repo' } | { kind: 'unknown'; error: string }; -/** Where a write to `file` lands (see realFilePath): the path every check here judges. */ -async function landingPath(file: string): Promise { - const { realFilePath } = await import('./mcp-reconcile.js'); - return realFilePath(file); -} /** * `file` as a message names it, and the path to give git for it: the one a @@ -105,7 +113,7 @@ async function landingPath(file: string): Promise { * symlink above the checkout (macOS /var) changes no path git uses. */ export async function gitPathOf(file: string): Promise<{ label: string; path: string }> { - const landed = await landingPath(file); + const landed = await realFilePath(file); if (landed === file) return { label: file, path: file }; const location = await gitExcludeFile(await existingAncestor(landed)); const inCheckout = location ? path.relative(location.root, landed) : ''; @@ -118,7 +126,7 @@ export async function gitPathOf(file: string): Promise<{ label: string; path: st * ignore rule. Judged where a write to it lands. Read-only. */ export async function gitTracking(file: string): Promise { - file = await landingPath(file); + file = await realFilePath(file); const dir = await existingAncestor(file); const result = await execCommand('git', ['check-ignore', '-q', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) .catch((e: unknown) => ({ code: -1, stdout: '', stderr: e instanceof Error ? e.message : String(e) })); @@ -137,7 +145,7 @@ export async function gitTracking(file: string): Promise { * answer: never read it as untracked. */ async function gitTracks(file: string): Promise<{ kind: 'tracked' } | { kind: 'untracked' } | { kind: 'unknown'; error: string }> { - file = await landingPath(file); + file = await realFilePath(file); // The file, or even its directory, may be gone from disk and still be in the index. const dir = await existingAncestor(file); const result = await execCommand('git', ['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) @@ -198,7 +206,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo if (inIndex.kind === 'tracked') return tracked(); if (inIndex.kind === 'unknown') return { kind: 'failed', reason: inIndex.error, fix: repair }; // Where the write lands. It and its directory need not exist yet: git is asked from the nearest one that does. - const landed = await landingPath(file); + const landed = await realFilePath(file); const dir = await existingAncestor(landed); const location = await gitExcludeFile(dir); if (!location) { diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 9ea3a94a9..ad6e3a6af 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -50,9 +50,9 @@ import { carriesResolvedValue, ensureExcludedFromGit, excludeFromGit, - existingAncestor, findMcpGitExcludes, mcpExcludePatternPath, + realFilePath, removeMcpGitExclude, resolvedVariableIn, type GitExclusion, @@ -1031,18 +1031,6 @@ async function releaseMcpGitExcludes( } } -/** - * Where a write to `file` lands: the real path of its closest existing - * directory, the rest appended. The appliers replace the file itself (tmp + - * rename) but follow its directories, so every check of whether git would - * commit the file judges this path (#886), and reads keep `file`. - */ -export async function realFilePath(file: string): Promise { - const dir = await existingAncestor(file); - const real = await fse.realpath(dir).catch(() => dir); - return path.join(real, path.relative(dir, file)); -} - async function reconcileTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, From 0c8d2dc673c4ee7f6b2833a8d691773f09743b96 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 12:02:42 +0200 Subject: [PATCH 41/85] fix(mcp): judge a config under an earlier teamai.yaml mapping as a recorded file, not by today's records (#882) --- src/__tests__/mcp-reconcile.test.ts | 49 ++++++++++++++++++++++++++++- src/mcp-git-exclude.ts | 2 +- src/mcp-reconcile.ts | 27 +++++++++++++--- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 898d1280a..8e8a996d4 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1222,11 +1222,58 @@ servers: expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); }); - it('leaves a file it does not find a resolved value in alone', async () => { + it.each([ + ['removed its server', async () => { + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + }], + ['renamed its server, whose variable is no longer set', async () => { + await writeMcpYaml(withSecret.replace('with-secret', 'renamed')); + vi.stubEnv('SECRET_TOKEN', ''); + }], + ])('is listed and recorded when the team also %s', async (_label, arrange) => { + await arrange(); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(customFile(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'] }); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + }); + + // No record describes that path any more, so a server of the member's own cannot be told from an older teamai's. + it('lists and records a file holding only a server of the member\'s own', async () => { await fse.writeJson(customFile(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'] }); + }); + + it('leaves a file git tracks alone: an exclude line does nothing for it', async () => { + await fse.writeJson(customFile(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + git(projectRoot, 'add', '-f', '.cursor/team-mcp.json'); + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'mine'); + vi.mocked(log.warn).mockClear(); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + expect(Object.keys((await ledger()).files)).not.toContain(customFile()); + expect(vi.mocked(log.warn).mock.calls.flat().join('\n')).not.toMatch(/team-mcp\.json/); + expect((await ledger()).earlierMappingsRead).toBe(true); + }); + + it('leaves a file that holds no server alone', async () => { + await fse.writeJson(customFile(), { mcpServers: {} }); + + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); expect(Object.keys((await ledger()).files)).not.toContain(customFile()); }); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 971275833..3b9acdfd2 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -144,7 +144,7 @@ export async function gitTracking(file: string): Promise { * it, and no exclude rule stops that. Read-only. `unknown` is git failing to * answer: never read it as untracked. */ -async function gitTracks(file: string): Promise<{ kind: 'tracked' } | { kind: 'untracked' } | { kind: 'unknown'; error: string }> { +export async function gitTracks(file: string): Promise<{ kind: 'tracked' } | { kind: 'untracked' } | { kind: 'unknown'; error: string }> { file = await realFilePath(file); // The file, or even its directory, may be gone from disk and still be in the index. const dir = await existingAncestor(file); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index ad6e3a6af..035485cfb 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -51,6 +51,7 @@ import { ensureExcludedFromGit, excludeFromGit, findMcpGitExcludes, + gitTracks, mcpExcludePatternPath, realFilePath, removeMcpGitExclude, @@ -605,7 +606,8 @@ const EARLIER_BUILTIN_MCP_PROJECT = { /** * The files earlier revisions of the team's teamai.yaml mapped a tool's * project MCP config to (`toolPaths..mcpProject`) that exist under the - * project root, and that no target in `known` and no file `cfg`'s worktree + * project root, that git does not track (no exclude line applies to one it + * does), and that no target in `known` and no file `cfg`'s worktree * recorded is (#882): a teamai from before managed-mcp-files.json may have * written a resolved value there, under a mapping the team changed before * this member's first pull on a teamai that records one, plus those under a @@ -650,7 +652,7 @@ export async function earlierMappedMcpTargets(cfg: LocalConfig, known: McpTarget const real = await realFilePath(file); const inside = path.relative(root, real); if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue; - if (reached.has(real) || !await pathExists(file)) continue; + if (reached.has(real) || !await pathExists(file) || (await gitTracks(file)).kind === 'tracked') continue; found.set(key, { tool, format, file, projectScope: true }); } } @@ -678,10 +680,25 @@ export async function recordedMcpFileEvidence(targets: McpTarget[]): Promise 0 - ? 'teamai wrote a resolved value to it under an earlier toolPaths mapping, and it still holds MCP servers' + ? 'teamai may have written a resolved value to it under an earlier toolPaths mapping, and it still holds MCP servers' : null; } +/** + * Why a file `earlierMappedMcpTargets` returned may hold a value an older + * teamai resolved, or null: judged as a recorded file is, since the + * manifest's records for its tool describe the file today's mapping reaches, + * not this one, plus the value scan. + */ +export async function earlierMappedMcpFileEvidence( + target: McpTarget, + teamDefs: McpServerDef[] | null, + vars: Record, + ctx: () => Promise, +): Promise { + return await recordedMcpFileEvidence([target]) ?? await resolvedValueEvidence(target, teamDefs, { owned: [] }, vars, ctx); +} + /** * What each of this worktree's project MCP configs holds, for * `settleResolvedMcpFiles`: each of `targets`' files, judged by `holds`, and @@ -949,8 +966,8 @@ async function protectProjectMcpConfigs( return null; }); for (const target of earlier ?? []) { - const owned = manifest[managedMcpManifestKey(target.tool, true)] ?? []; - observations.push({ file: target.file, tool: target.tool, state: await mcpFileState([target]), holding: await holds(target, owned), owned: [] }); + const holding = await earlierMappedMcpFileEvidence(target, teamDefs, vars, ctx) !== null; + observations.push({ file: target.file, tool: target.tool, state: await mcpFileState([target]), holding, owned: [] }); } const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); From a30f56d5e549cb6ea2f0e8187c6e3374abe5fbb7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 12:02:42 +0200 Subject: [PATCH 42/85] fix(mcp): have doctor check the configs earlier teamai.yaml mappings reach until a pull reads them (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 59 +++++++++++++++++++++++ src/doctor-delivery.ts | 10 ++++ 2 files changed, 69 insertions(+) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 2443b7baa..e57318e5a 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -348,6 +348,65 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).not.toContain(path.join(projectRoot, '.mcp.json')); }); + describe('a config an older teamai wrote under a mapping an earlier teamai.yaml made, before a pull on this version', () => { + const old = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); + const commitTeamYaml = (toolPaths: object): void => { + fse.writeFileSync(path.join(repoPath, 'teamai.yaml'), JSON.stringify({ team: 't', toolPaths })); + execFileSync('git', ['add', '-A'], { cwd: repoPath }); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'toolPaths'], { cwd: repoPath }); + }; + + beforeEach(async () => { + execFileSync('git', ['init', '-q'], { cwd: repoPath }); + commitTeamYaml({ ...teamConfig.toolPaths, cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/team-mcp.json' } }); + commitTeamYaml(teamConfig.toolPaths ?? {}); + // Its server left mcp.yaml since, and no record names the file. + await fse.outputJson(old(), { + mcpServers: { gone: { type: 'http', url: 'https://gone.example/mcp', headers: { Authorization: 'Bearer t0ken' } } }, + }); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + }); + + it('fails, naming it, without writing managed-mcp-files.json', async () => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(old())).toHaveLength(2); + expect(check.fix).not.toContain(path.join(projectRoot, '.mcp.json')); + expect(await fse.pathExists(resolvedMcpFilesPath(localConfig) ?? '')).toBe(false); + }); + + it('passes once it is kept out of git', async () => { + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.cursor/team-mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(true); + }); + + it.each([ + ['a pull on this version has read those mappings', async () => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.outputJson(resolvedMcpFilesPath(localConfig) ?? '', { version: 1, files: {}, earlierMappingsRead: true }); + }], + ['git tracks it', async () => { + execFileSync('git', ['add', '-f', '.cursor/team-mcp.json'], { cwd: projectRoot }); + }], + ['git cannot read the team repo\'s history', async () => { + await fse.emptyDir(path.join(repoPath, '.git', 'objects')); + }], + ])('does not name it when %s', async (_label, arrange) => { + await arrange(); + + const check = await excludeCheck(); + if (check) expect(check.fix).not.toContain(old()); + // .mcp.json is listed, so nothing is left to fail on. + if (check) expect(await check.check()).toBe(true); + }); + }); + it('fails for a server that was in the file when a pull rebuilt the lost record, after it left mcp.yaml', async () => { const { trackResolvedMcpFiles, recordUnverifiedMcpServers } = await import('../mcp-resolved-files.js'); const file = path.join(projectRoot, '.mcp.json'); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index a5ad5a223..0aa00f834 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -526,6 +526,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise null) ?? []; + for (const target of earlier) { + vars ??= await buildVarTable(localConfig); + if (!holding.has(target.file) && await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired)) await hold(target.file); + } + } if (holding.size === 0) return []; return [{ From 5d9df20ec740c886c1e5b665a52bbd34242b7a25 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 12:02:48 +0200 Subject: [PATCH 43/85] docs(mcp): describe how a config under an earlier teamai.yaml mapping is judged, and doctor's check of it (#882) --- docs/designs/data-directory-layout.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index ad2c819c2..8ab62f2a4 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -437,7 +437,7 @@ every checkout, so that is where they live now: ├── pending-learnings/ pendingLearningsDir → /pending-learnings └── workspaces// ├── managed-mcp.json managedMcpManifestPath, one per checkout - ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs a pull wrote a resolved ${VAR} to, and whether + ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether │ the paths earlier teamai.yaml revisions mapped were read (#882) └── search-index.json getProjectSearchIndexPath, one per checkout /.teamai/ one per checkout: committed knowledge, knowledge-wt/ diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 62b40965e..ee58bd5e3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, and inside the project only), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, and only files git does not track; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path, and `teamai doctor` checks the same files until that pull), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 05057475a..4fe57d266 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的路径),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内、未被 git 跟踪的文件;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径,在那次 pull 之前 `teamai doctor` 也会检查这些文件),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 1c68c0c47..bb8db9113 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -53,7 +53,9 @@ once its file no longer holds a resolved value; `teamai uninstall` does so in every worktree. A file written under a `toolPaths..mcpProject` the team later changes or removes stays listed until it is deleted or holds no server; for one an older teamai wrote, the first pull finds the path in the team repo's -history of `teamai.yaml`, or among the built-in paths teamai has since changed. +history of `teamai.yaml`, or among the built-in paths teamai has since changed, +and lists it while it holds any server (not one git tracks); `teamai doctor` +checks those paths until that pull. ## Invite a member From 5696b3645db45423d6b80de04e56c80672c09ae0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 12:48:43 +0200 Subject: [PATCH 44/85] fix(mcp): record a config under an earlier teamai.yaml mapping that git tracks, and judge it once git no longer does (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 24 ++++++++++ src/__tests__/mcp-reconcile.test.ts | 42 +++++++++++++++- src/__tests__/mcp-resolved-files.test.ts | 17 +++++++ src/doctor-delivery.ts | 12 +++-- src/mcp-reconcile.ts | 58 ++++++++++++++--------- src/mcp-resolved-files.ts | 25 ++++++++-- 6 files changed, 145 insertions(+), 33 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index e57318e5a..747cbf335 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -405,6 +405,30 @@ describe('doctor — MCP servers delivered on disk', () => { // .mcp.json is listed, so nothing is left to fail on. if (check) expect(await check.check()).toBe(true); }); + + describe('recorded as tracked by a pull that found git tracking it', () => { + beforeEach(async () => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.outputJson(resolvedMcpFilesPath(localConfig) ?? '', { + version: 1, files: { [old()]: { tools: ['cursor'], tracked: true } }, earlierMappingsRead: true, + }); + }); + + it('does not name it while git tracks it', async () => { + execFileSync('git', ['add', '-f', '.cursor/team-mcp.json'], { cwd: projectRoot }); + + const check = await excludeCheck(); + if (check) expect(check.fix).not.toContain(old()); + if (check) expect(await check.check()).toBe(true); + }); + + it('fails, naming it, once git no longer tracks it', async () => { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(old())).toHaveLength(2); + }); + }); }); it('fails for a server that was in the file when a pull rebuilt the lost record, after it left mcp.yaml', async () => { diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 8e8a996d4..146ac3675 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1255,20 +1255,58 @@ servers: expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'] }); }); - it('leaves a file git tracks alone: an exclude line does nothing for it', async () => { + it('lists nothing for a file git tracks, and records it as tracked: an exclude line does nothing for it', async () => { await fse.writeJson(customFile(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); git(projectRoot, 'add', '-f', '.cursor/team-mcp.json'); git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'mine'); vi.mocked(log.warn).mockClear(); + await reconcileMcpForConfig(teamConfig, projectConfig); await reconcileMcpForConfig(teamConfig, projectConfig); expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); - expect(Object.keys((await ledger()).files)).not.toContain(customFile()); + expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'], tracked: true }); expect(vi.mocked(log.warn).mock.calls.flat().join('\n')).not.toMatch(/team-mcp\.json/); expect((await ledger()).earlierMappingsRead).toBe(true); }); + describe('once git tracks it', () => { + const commitIt = (): void => { + git(projectRoot, 'add', '-f', '.cursor/team-mcp.json'); + git(projectRoot, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'old'); + }; + + it('is listed and recorded as any other on the first pull after the member stops git tracking it', async () => { + commitIt(); + await reconcileMcpForConfig(teamConfig, projectConfig); + git(projectRoot, 'rm', '-q', '--cached', '.cursor/team-mcp.json'); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(customFile(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/team-mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\?\? .*team-mcp\.json/); + expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'] }); + }); + + // A checkout brings back what git holds, so only a file gone from both is forgotten. + it('keeps its record while git tracks it, whatever the file holds, and forgets it once it is gone from git and disk', async () => { + commitIt(); + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.writeJson(customFile(), { mcpServers: {} }); + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.remove(customFile()); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect((await ledger()).files[customFile()]).toEqual({ tools: ['cursor'], tracked: true }); + git(projectRoot, 'rm', '-q', '--cached', '.cursor/team-mcp.json'); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(Object.keys((await ledger()).files)).not.toContain(customFile()); + expect(await excludeOf(projectRoot)).not.toMatch(/team-mcp\.json/); + }); + }); + it('leaves a file that holds no server alone', async () => { await fse.writeJson(customFile(), { mcpServers: {} }); diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts index 4e47f85ec..1a221275d 100644 --- a/src/__tests__/mcp-resolved-files.test.ts +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -201,5 +201,22 @@ describe('managed-mcp-files.json', () => { { file: other, tool: 'claude', state: { kind: 'parsed', servers: ['open'] }, holding: false, owned: ['open'] }, ])).toBe('unchanged'); }); + + it('records a file git tracks as tracked, and keeps it whatever it holds while git does', async () => { + const old = path.join(tmp, 'project', '.cursor', 'team-mcp.json'); + + await settleResolvedMcpFiles(cfg, [{ file: old, tool: 'cursor', state: { kind: 'parsed', servers: ['mine'] }, holding: false, owned: [], tracked: true }]); + await settleResolvedMcpFiles(cfg, [{ file: old, tool: 'cursor', state: { kind: 'missing' }, holding: false, owned: [], tracked: true }]); + + expect((await readResolvedMcpFiles(cfg)).files[old]).toEqual({ tools: ['cursor'], tracked: true }); + }); + + it('makes a tracked record an ordinary one once git no longer tracks the file', async () => { + await fse.outputJson(sidecar, { version: 1, files: { [custom()]: { tools: ['claude'], tracked: true } } }); + + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'claude', state: { kind: 'parsed', servers: ['jira'] }, holding: true, owned: [], tracked: false }]); + + expect((await readResolvedMcpFiles(cfg)).files[custom()]).toEqual({ tools: ['claude'] }); + }); }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 0aa00f834..425170f12 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -529,7 +529,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise null) ?? []; - for (const target of earlier) { + for (const { tracked, ...target } of earlier) { vars ??= await buildVarTable(localConfig); - if (!holding.has(target.file) && await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired)) await hold(target.file); + if (!tracked && !holding.has(target.file) && await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired)) await hold(target.file); } } if (holding.size === 0) return []; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 035485cfb..23d8c6221 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -577,22 +577,29 @@ function once(load: () => Promise): () => Promise { return () => value ??= load(); } +/** A file `recordedMcpTargets` returns. */ +export interface RecordedMcpFile { + /** A target per tool it was written for. */ + targets: McpTarget[]; + /** Recorded as one git tracked (managed-mcp-files.json): no line protects it while git does. */ + tracked: boolean; +} + /** * The files `cfg`'s worktree recorded writing a resolved value to (#882) that * no target in `known` is: the team has since changed or removed the - * toolPaths mapping they were written under. Each with a target per tool it - * was written for. + * toolPaths mapping they were written under. */ -export async function recordedMcpTargets(cfg: LocalConfig, known: McpTarget[]): Promise> { +export async function recordedMcpTargets(cfg: LocalConfig, known: McpTarget[]): Promise> { const mapped = new Set(await Promise.all(known.map((target) => realFilePath(target.file)))); - const recorded = new Map(); - for (const [file, { tools }] of Object.entries((await readResolvedMcpFiles(cfg)).files)) { + const recorded = new Map(); + for (const [file, entry] of Object.entries((await readResolvedMcpFiles(cfg)).files)) { if (mapped.has(await realFilePath(file))) continue; - const targets = tools.flatMap((tool): McpTarget[] => { + const targets = entry.tools.flatMap((tool): McpTarget[] => { const format = detectMcpFormat(tool); return format ? [{ tool, format, file, projectScope: true }] : []; }); - if (targets.length > 0) recorded.set(file, targets); + if (targets.length > 0) recorded.set(file, { targets, tracked: entry.tracked === true }); } return recorded; } @@ -606,9 +613,9 @@ const EARLIER_BUILTIN_MCP_PROJECT = { /** * The files earlier revisions of the team's teamai.yaml mapped a tool's * project MCP config to (`toolPaths..mcpProject`) that exist under the - * project root, that git does not track (no exclude line applies to one it - * does), and that no target in `known` and no file `cfg`'s worktree - * recorded is (#882): a teamai from before managed-mcp-files.json may have + * project root, and that no target in `known` and no file `cfg`'s worktree + * recorded is (#882), each saying whether git tracks it (no exclude line + * applies to one it does): a teamai from before managed-mcp-files.json may have * written a resolved value there, under a mapping the team changed before * this member's first pull on a teamai that records one, plus those under a * built-in default teamai has since changed. Read from the team @@ -616,7 +623,10 @@ const EARLIER_BUILTIN_MCP_PROJECT = { * has less). Null when git cannot read it: not a repository, no commits, a * git error. */ -export async function earlierMappedMcpTargets(cfg: LocalConfig, known: McpTarget[]): Promise { +export async function earlierMappedMcpTargets( + cfg: LocalConfig, + known: McpTarget[], +): Promise | null> { const { projectRoot } = cfg; if (!projectRoot) return []; const repoPath = cfg.repo.localPath; @@ -631,7 +641,7 @@ export async function earlierMappedMcpTargets(cfg: LocalConfig, known: McpTarget const reached = new Set(await Promise.all( [...known.map((target) => target.file), ...Object.keys((await readResolvedMcpFiles(cfg)).files)].map(realFilePath), )); - const found = new Map(); + const found = new Map(); for (const revision of [null, ...revisions]) { let toolPaths: unknown = EARLIER_BUILTIN_MCP_PROJECT; if (revision !== null) { @@ -652,8 +662,8 @@ export async function earlierMappedMcpTargets(cfg: LocalConfig, known: McpTarget const real = await realFilePath(file); const inside = path.relative(root, real); if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue; - if (reached.has(real) || !await pathExists(file) || (await gitTracks(file)).kind === 'tracked') continue; - found.set(key, { tool, format, file, projectScope: true }); + if (reached.has(real) || !await pathExists(file)) continue; + found.set(key, { tool, format, file, projectScope: true, tracked: (await gitTracks(file)).kind === 'tracked' }); } } return [...found.values()]; @@ -702,7 +712,8 @@ export async function earlierMappedMcpFileEvidence( /** * What each of this worktree's project MCP configs holds, for * `settleResolvedMcpFiles`: each of `targets`' files, judged by `holds`, and - * each file `recordedMcpTargets` returns, by `recordedMcpFileEvidence`. + * each file `recordedMcpTargets` returns, by `recordedMcpFileEvidence`, but + * for one recorded as tracked that git still tracks: no line protects it. */ async function observeMcpConfigs( localConfig: LocalConfig, @@ -716,10 +727,11 @@ async function observeMcpConfigs( const state = await mcpFileState([target]); observations.push({ file: target.file, tool: target.tool, state, holding: await holds(target, owned), owned: owned.map((r) => r.name) }); } - for (const [file, group] of await recordedMcpTargets(localConfig, targets)) { - const holding = await recordedMcpFileEvidence(group) !== null; + for (const [file, { targets: group, tracked }] of await recordedMcpTargets(localConfig, targets)) { const state = await mcpFileState(group); - for (const { tool } of group) observations.push({ file, tool, state, holding, owned: [] }); + const stillTracked = tracked && (await gitTracks(file)).kind === 'tracked'; + const holding = !stillTracked && await recordedMcpFileEvidence(group) !== null; + for (const { tool } of group) observations.push({ file, tool, state, holding, owned: [], ...tracked ? { tracked: stillTracked } : {} }); } return observations; } @@ -827,7 +839,7 @@ export async function mcpConfigsNotProvenClean( // Files a pull wrote under a mapping since changed, in any worktree: nothing but the file itself can judge them. const recorded = new Map(); for (const [cfg, cfgTargets] of recordedBy) { - for (const [file, group] of await recordedMcpTargets(cfg, cfgTargets)) { + for (const [file, { targets: group }] of await recordedMcpTargets(cfg, cfgTargets)) { const key = await realFilePath(file); if (!targets.has(key)) recorded.set(key, group); } @@ -965,9 +977,11 @@ async function protectProjectMcpConfigs( log.debug(`Did not read the MCP configs earlier toolPaths mappings reach: ${e instanceof Error ? e.message : String(e)}`); return null; }); - for (const target of earlier ?? []) { - const holding = await earlierMappedMcpFileEvidence(target, teamDefs, vars, ctx) !== null; - observations.push({ file: target.file, tool: target.tool, state: await mcpFileState([target]), holding, owned: [] }); + for (const { tracked, ...target } of earlier ?? []) { + const state = await mcpFileState([target]); + // No line protects a file git tracks: recorded as tracked, whatever it holds, and judged once git no longer tracks it. + if (tracked) observations.push({ file: target.file, tool: target.tool, state, holding: false, owned: [], tracked }); + else observations.push({ file: target.file, tool: target.tool, state, holding: await earlierMappedMcpFileEvidence(target, teamDefs, vars, ctx) !== null, owned: [] }); } const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts index 9562b9f6f..acaafe2d7 100644 --- a/src/mcp-resolved-files.ts +++ b/src/mcp-resolved-files.ts @@ -15,14 +15,18 @@ import { updateFileLocked, type ExcludeUpdate } from './mcp-git-exclude.js'; // it for, and the servers it found there when it rebuilt a lost record. It // also says whether a pull has read the files earlier revisions of the team's // teamai.yaml mapped, which a teamai from before this file wrote to without -// recording them. Nothing depends on it to keep a line: missing or unreadable, -// it reads as empty and the rules without it apply. +// recording them, and remembers one of those git tracked, which no line can +// protect until the member stops git tracking it. Nothing depends on it to +// keep a line: missing or unreadable, it reads as empty and the rules without +// it apply. export interface ResolvedMcpFile { /** The tools whose MCP format the file was written in. */ tools: string[]; /** Servers in the file when teamai rebuilt its lost record: teamai may have written them. */ unverified?: string[]; + /** Git tracked it when a pull found it under an earlier teamai.yaml mapping: judged once git no longer does. */ + tracked?: true; } export interface ResolvedMcpFiles { @@ -42,6 +46,8 @@ export interface McpFileObservation { holding: boolean; /** The server names managed-mcp.json records for it now. */ owned: string[]; + /** Whether git tracks it, for a file recorded (or to record) as one it tracked: kept, whatever it holds, while git does. */ + tracked?: boolean; } // Fields a later teamai adds are carried through a rewrite. @@ -153,7 +159,9 @@ export function recordUnverifiedMcpServers(cfg: LocalConfig, found: Array<{ file * Bring the record up to date with what the files hold: forget a file that is * gone or holds no server, record one holding a resolved value it did not * list (written by an older teamai), and drop a noted server that left its - * file or that teamai owns again. A file that does not parse stays as it is. + * file or that teamai owns again. A file that does not parse stays as it is, + * and so does one git tracks that was recorded as tracked: a checkout brings + * back what git holds. * `earlierMappingsRead`: the observations cover the files earlier revisions * of teamai.yaml mapped, which later pulls need not read again. */ @@ -166,13 +174,22 @@ export function settleResolvedMcpFiles( const { files } = sidecar; let changed = options.earlierMappingsRead === true && sidecar.earlierMappingsRead !== true; if (changed) sidecar.earlierMappingsRead = true; - for (const { file, tool, state, holding, owned } of observations) { + for (const { file, tool, state, holding, owned, tracked } of observations) { const entry = files[file]; + if (tracked === true) { + if (!entry) files[file] = { tools: [tool], tracked: true }; + changed ||= !entry; + continue; + } if (state.kind === 'missing' || (state.kind === 'parsed' && state.servers.length === 0)) { if (entry) delete files[file]; changed ||= entry !== undefined; continue; } + if (entry?.tracked === true && tracked === false) { + delete entry.tracked; + changed = true; + } if (!entry) { if (holding) files[file] = { tools: [tool] }; changed ||= holding; From fbed13d23673384775397d7435c6340c2ef5155e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 12:50:15 +0200 Subject: [PATCH 45/85] fix(mcp): keep judging a recorded config for a tool the team moved while another tool still maps it (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 37 +++++++++++++ src/__tests__/mcp-reconcile.test.ts | 51 ++++++++++++++++++ src/__tests__/mcp-resolved-files.test.ts | 15 ++++++ src/doctor-delivery.ts | 11 ++-- src/mcp-reconcile.ts | 64 +++++++++++++++++------ src/mcp-resolved-files.ts | 21 ++++++-- 6 files changed, 174 insertions(+), 25 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 747cbf335..19fa9fc30 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -431,6 +431,43 @@ describe('doctor — MCP servers delivered on disk', () => { }); }); + describe('a config written for a tool the team has since moved, that another tool\'s mapping still reaches', () => { + const file = (): string => path.join(projectRoot, '.mcp.json'); + + beforeEach(async () => { + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + teamConfig.toolPaths = { + claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, + cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' }, + }; + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + // Claude's own entry, and the one a pull wrote there for Cursor with a token before the team moved it. + await fse.writeJson(file(), { + mcpServers: { + docs: { type: 'http', url: 'https://docs.example/mcp' }, + gone: { type: 'http', url: 'https://gone.example/mcp', headers: { Authorization: 'Bearer t0ken' } }, + }, + }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'h', resolved: false }], + }); + expect(await trackResolvedMcpFiles(localConfig, [{ tool: 'cursor', file: file() }])).toBe('written'); + }); + + it('fails, naming it once, while it holds a server none of the tools mapping it own', async () => { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(file())).toHaveLength(2); + }); + + it('emits no check once only their servers are left', async () => { + await fse.writeJson(file(), { mcpServers: { docs: { type: 'http', url: 'https://docs.example/mcp' } } }); + + expect(await excludeCheck()).toBeUndefined(); + }); + }); + it('fails for a server that was in the file when a pull rebuilt the lost record, after it left mcp.yaml', async () => { const { trackResolvedMcpFiles, recordUnverifiedMcpServers } = await import('../mcp-resolved-files.js'); const file = path.join(projectRoot, '.mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 146ac3675..b88f2ed5f 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1381,6 +1381,57 @@ servers: }); }); + describe('a config written for a tool the team has since moved, that another tool\'s mapping still reaches', () => { + const shared = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.mcp.json' } }; + const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); + const ledger = async (): Promise> => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + return (await readResolvedMcpFiles(projectConfig)).files; + }; + const setServers = async (servers: Record): Promise => { + const doc = await fse.readJson(mcpJson()) as { mcpServers: Record }; + await fse.writeJson(mcpJson(), { mcpServers: { open: doc.mcpServers.open, ...servers } }); + }; + + beforeEach(async () => { + // Cursor's own server, with the token, lands in the file Claude maps too. + await writeMcpYaml(`${withSecret} tools: [cursor]\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n`); + await reconcileMcpForConfig({ ...teamConfig, toolPaths: shared } as TeamaiConfig, projectConfig); + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + // Then the team moves Cursor back to its own file and drops that server; the token is no longer set. + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'); + vi.stubEnv('SECRET_TOKEN', ''); + }); + + it('keeps its line while the file holds that tool\'s server', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.mcp\.json/); + }); + + // As for any recorded file: nothing tells the member's server from one teamai wrote there for Cursor. + it('keeps it while the file holds a server of the member\'s own', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + await setServers({ mine: { type: 'http', url: 'https://mine.example/mcp' } }); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('lets the line go, and takes that tool off the record, once only servers the tools mapping it own are left', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + await setServers({}); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + expect(Object.keys(await ledger())).not.toContain(mcpJson()); + }); + }); + it('lists the config in .git/info/exclude before writing the value into it', async () => { await writeMcpYaml(withSecret); diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts index 1a221275d..b524170a7 100644 --- a/src/__tests__/mcp-resolved-files.test.ts +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -218,5 +218,20 @@ describe('managed-mcp-files.json', () => { expect((await readResolvedMcpFiles(cfg)).files[custom()]).toEqual({ tools: ['claude'] }); }); + + it('takes a tool another now maps the file for off the record once it holds nothing of that tool\'s, and the file with its last one', async () => { + await fse.outputJson(sidecar, { version: 1, files: { [custom()]: { tools: ['claude', 'cursor'] }, [cursor()]: { tools: ['codebuddy'] } } }); + const state = { kind: 'parsed', servers: ['open'] } as const; + + await settleResolvedMcpFiles(cfg, [ + { file: custom(), tool: 'cursor', state, holding: false, owned: ['open'], remapped: true }, + { file: cursor(), tool: 'codebuddy', state, holding: false, owned: ['open'], remapped: true }, + ]); + expect(await settleResolvedMcpFiles(cfg, [ + { file: custom(), tool: 'claude', state, holding: true, owned: ['open'], remapped: true }, + ])).toBe('unchanged'); + + expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [custom()]: { tools: ['claude'] } }); + }); }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 425170f12..605c795be 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -562,10 +562,13 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + if (await recordedMcpFileEvidence(group, owned)) await hold(file); } // And, until a pull on this version reads them, those an older teamai wrote under a mapping an earlier // teamai.yaml made. Read-only: the record of that read is pull's. Unreadable history skips them. diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 23d8c6221..66cf73439 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -579,27 +579,32 @@ function once(load: () => Promise): () => Promise { /** A file `recordedMcpTargets` returns. */ export interface RecordedMcpFile { - /** A target per tool it was written for. */ + /** A target per tool it was recorded for whose mapping in `known` no longer reaches it. */ targets: McpTarget[]; + /** The tools whose target in `known` reaches it: their manifest records tell their own servers there. */ + mappedBy: string[]; /** Recorded as one git tracked (managed-mcp-files.json): no line protects it while git does. */ tracked: boolean; } /** - * The files `cfg`'s worktree recorded writing a resolved value to (#882) that - * no target in `known` is: the team has since changed or removed the - * toolPaths mapping they were written under. + * The files `cfg`'s worktree recorded writing a resolved value to (#882) for + * a tool no target in `known` reaches them for: the team has since changed or + * removed the toolPaths mapping they were written under. A file another + * tool's target reaches is among them while a tool it was recorded for is not + * one of those. */ export async function recordedMcpTargets(cfg: LocalConfig, known: McpTarget[]): Promise> { - const mapped = new Set(await Promise.all(known.map((target) => realFilePath(target.file)))); + const reach = await Promise.all(known.map(async (target) => ({ tool: target.tool, real: await realFilePath(target.file) }))); const recorded = new Map(); for (const [file, entry] of Object.entries((await readResolvedMcpFiles(cfg)).files)) { - if (mapped.has(await realFilePath(file))) continue; - const targets = entry.tools.flatMap((tool): McpTarget[] => { + const real = await realFilePath(file); + const mappedBy = [...new Set(reach.filter((r) => r.real === real).map((r) => r.tool))]; + const targets = entry.tools.filter((tool) => !mappedBy.includes(tool)).flatMap((tool): McpTarget[] => { const format = detectMcpFormat(tool); return format ? [{ tool, format, file, projectScope: true }] : []; }); - if (targets.length > 0) recorded.set(file, { targets, tracked: entry.tracked === true }); + if (targets.length > 0) recorded.set(file, { targets, mappedBy, tracked: entry.tracked === true }); } return recorded; } @@ -685,13 +690,23 @@ async function mcpFileState(targets: McpTarget[]): Promise { +export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: readonly string[]): Promise { const state = await mcpFileState(targets); if (state.kind === 'unparsable') return 'it does not parse'; - return state.kind === 'parsed' && state.servers.length > 0 - ? 'teamai may have written a resolved value to it under an earlier toolPaths mapping, and it still holds MCP servers' - : null; + if (state.kind !== 'parsed') return null; + if (!owned) { + return state.servers.length > 0 + ? 'teamai may have written a resolved value to it under an earlier toolPaths mapping, and it still holds MCP servers' + : null; + } + const other = state.servers.find((name) => !owned.includes(name)); + return other === undefined ? null + : `teamai may have written a resolved value to it for ${targets.map((t) => t.tool).join(', ')} under an earlier toolPaths mapping, ` + + `and it holds ${other}, which no tool that maps it now owns`; } /** @@ -727,11 +742,19 @@ async function observeMcpConfigs( const state = await mcpFileState([target]); observations.push({ file: target.file, tool: target.tool, state, holding: await holds(target, owned), owned: owned.map((r) => r.name) }); } - for (const [file, { targets: group, tracked }] of await recordedMcpTargets(localConfig, targets)) { + for (const [file, { targets: group, mappedBy, tracked }] of await recordedMcpTargets(localConfig, targets)) { const state = await mcpFileState(group); const stillTracked = tracked && (await gitTracks(file)).kind === 'tracked'; - const holding = !stillTracked && await recordedMcpFileEvidence(group) !== null; - for (const { tool } of group) observations.push({ file, tool, state, holding, owned: [], ...tracked ? { tracked: stillTracked } : {} }); + const owned = mappedBy.length === 0 ? undefined + : mappedBy.flatMap((tool) => manifest[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + const holding = !stillTracked && await recordedMcpFileEvidence(group, owned) !== null; + for (const { tool } of group) { + observations.push({ + file, tool, state, holding, owned: owned ?? [], + ...tracked ? { tracked: stillTracked } : {}, + ...owned && !stillTracked ? { remapped: true as const } : {}, + }); + } } return observations; } @@ -836,12 +859,15 @@ export async function mcpConfigsNotProvenClean( }); } } - // Files a pull wrote under a mapping since changed, in any worktree: nothing but the file itself can judge them. + // Files a pull wrote under a mapping since changed, in any worktree: nothing but the file itself can judge them, + // and in one another tool now maps, nothing but that tool's records. const recorded = new Map(); + const remapped = new Map(); for (const [cfg, cfgTargets] of recordedBy) { for (const [file, { targets: group }] of await recordedMcpTargets(cfg, cfgTargets)) { const key = await realFilePath(file); if (!targets.has(key)) recorded.set(key, group); + else remapped.set(key, [...remapped.get(key) ?? [], ...group]); } } // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. @@ -865,6 +891,12 @@ export async function mcpConfigsNotProvenClean( if (why) held.set(file, why); continue; } + const moved = remapped.get(file); + const movedWhy = moved && await recordedMcpFileEvidence(moved, targets.get(file)?.owned.map((record) => record.name) ?? []); + if (movedWhy) { + held.set(file, movedWhy); + continue; + } const known = targets.get(file) ?? (sibling && nested ? { target: { ...sibling.target, file }, owned: [], unverified: [], recorded: false, foreign: true, nested } : undefined); const installed = known ? await installedMcpEntries(known.target) : null; diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts index acaafe2d7..0f38d4484 100644 --- a/src/mcp-resolved-files.ts +++ b/src/mcp-resolved-files.ts @@ -48,6 +48,8 @@ export interface McpFileObservation { owned: string[]; /** Whether git tracks it, for a file recorded (or to record) as one it tracked: kept, whatever it holds, while git does. */ tracked?: boolean; + /** `tool` no longer maps the file, another tool does: `holding` says whether it holds what teamai may have written for `tool`. */ + remapped?: true; } // Fields a later teamai adds are carried through a rewrite. @@ -158,10 +160,11 @@ export function recordUnverifiedMcpServers(cfg: LocalConfig, found: Array<{ file /** * Bring the record up to date with what the files hold: forget a file that is * gone or holds no server, record one holding a resolved value it did not - * list (written by an older teamai), and drop a noted server that left its - * file or that teamai owns again. A file that does not parse stays as it is, - * and so does one git tracks that was recorded as tracked: a checkout brings - * back what git holds. + * list (written by an older teamai), take a tool off a file another tool now + * maps once the file holds nothing teamai may have written for it, and drop a + * noted server that left its file or that teamai owns again. A file that does + * not parse stays as it is, and so does one git tracks that was recorded as + * tracked: a checkout brings back what git holds. * `earlierMappingsRead`: the observations cover the files earlier revisions * of teamai.yaml mapped, which later pulls need not read again. */ @@ -174,7 +177,7 @@ export function settleResolvedMcpFiles( const { files } = sidecar; let changed = options.earlierMappingsRead === true && sidecar.earlierMappingsRead !== true; if (changed) sidecar.earlierMappingsRead = true; - for (const { file, tool, state, holding, owned, tracked } of observations) { + for (const { file, tool, state, holding, owned, tracked, remapped } of observations) { const entry = files[file]; if (tracked === true) { if (!entry) files[file] = { tools: [tool], tracked: true }; @@ -190,6 +193,14 @@ export function settleResolvedMcpFiles( delete entry.tracked; changed = true; } + if (remapped) { + if (holding || !entry?.tools.includes(tool)) continue; + const tools = entry.tools.filter((t) => t !== tool); + if (tools.length > 0 || entry.unverified) files[file] = { ...entry, tools }; + else delete files[file]; + changed = true; + continue; + } if (!entry) { if (holding) files[file] = { tools: [tool] }; changed ||= holding; From 16c1938f471678bfff139418cc267deba44cda0c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 12:51:15 +0200 Subject: [PATCH 46/85] docs(mcp): describe the tracked config an earlier mapping reached, and a moved tool's config another tool still maps (#882) --- docs/designs/data-directory-layout.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 7 +++++-- skill-data/setup/references/uninstall.md | 7 ++++--- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 8ab62f2a4..cbe5025da 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -438,7 +438,7 @@ every checkout, so that is where they live now: └── workspaces// ├── managed-mcp.json managedMcpManifestPath, one per checkout ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether - │ the paths earlier teamai.yaml revisions mapped were read (#882) + │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882) └── search-index.json getProjectSearchIndexPath, one per checkout /.teamai/ one per checkout: committed knowledge, knowledge-wt/ ``` diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ee58bd5e3..f7d75d212 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, and only files git does not track; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path, and `teamai doctor` checks the same files until that pull), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path, and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere, that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 4fe57d266..1c92e73d3 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内、未被 git 跟踪的文件;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径,在那次 pull 之前 `teamai doctor` 也会检查这些文件),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径,在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index bb8db9113..e0e5c3409 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -54,8 +54,11 @@ every worktree. A file written under a `toolPaths..mcpProject` the team later changes or removes stays listed until it is deleted or holds no server; for one an older teamai wrote, the first pull finds the path in the team repo's history of `teamai.yaml`, or among the built-in paths teamai has since changed, -and lists it while it holds any server (not one git tracks); `teamai doctor` -checks those paths until that pull. +and lists it while it holds any server; one git tracks is recorded instead and +listed once the member runs `git rm --cached` on it. `teamai doctor` checks +those paths until that pull. A file written for a tool the team moved elsewhere, +that another tool still maps, stays listed while it holds a server that tool did +not write, one of the member's own included. ## Invite a member diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index e84b7134f..a13394d2a 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -61,7 +61,8 @@ and give it your team repo URL."* under a symlinked directory, the link's target (`/config/mcp.json` for `.cursor/` linking to `config/`). For one it cannot prove clean (including one written under a `toolPaths` mapping since changed, or in a nested repository's linked - worktree, that still holds servers) it keeps the line and warns, naming the - file and why: have the user remove teamai's servers from that - file, then delete the line (with the last one, the block's markers). Do not + worktree, that still holds servers, and one written for a tool since moved that + another tool maps, holding a server that tool did not write) it keeps the line + and warns, naming the file and why: have the user remove teamai's servers from + that file, then delete the line (with the last one, the block's markers). Do not delete a kept line while its file still holds a token. From fc807e01842f572c5925d78ef7375d9ad78047bc Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 13:11:09 +0200 Subject: [PATCH 47/85] fix(mcp): find a config an older teamai wrote under an earlier mapping another tool maps today, and judge it by that tool's records (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 27 +++++++++ src/__tests__/mcp-reconcile.test.ts | 73 +++++++++++++++++++++++ src/__tests__/mcp-resolved-files.test.ts | 24 ++++++++ src/doctor-delivery.ts | 8 ++- src/mcp-reconcile.ts | 54 +++++++++++------ src/mcp-resolved-files.ts | 32 ++++++---- 6 files changed, 188 insertions(+), 30 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 19fa9fc30..56c5a643d 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -466,6 +466,33 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await excludeCheck()).toBeUndefined(); }); + + describe('written by an older teamai under a mapping only an earlier teamai.yaml made, before a pull on this version', () => { + beforeEach(async () => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.remove(resolvedMcpFilesPath(localConfig) ?? ''); + execFileSync('git', ['init', '-q'], { cwd: repoPath }); + for (const cursorFile of ['.mcp.json', '.cursor/mcp.json']) { + const toolPaths = { ...teamConfig.toolPaths, cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: cursorFile } }; + fse.writeFileSync(path.join(repoPath, 'teamai.yaml'), JSON.stringify({ team: 't', toolPaths })); + execFileSync('git', ['add', '-A'], { cwd: repoPath }); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'toolPaths'], { cwd: repoPath }); + } + }); + + it('fails, naming it once, while it holds a server none of the tools mapping it own', async () => { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(file())).toHaveLength(2); + }); + + it('emits no check once only their servers are left', async () => { + await fse.writeJson(file(), { mcpServers: { docs: { type: 'http', url: 'https://docs.example/mcp' } } }); + + expect(await excludeCheck()).toBeUndefined(); + }); + }); }); it('fails for a server that was in the file when a pull rebuilt the lost record, after it left mcp.yaml', async () => { diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index b88f2ed5f..7a73a4b99 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1430,6 +1430,79 @@ servers: expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); expect(Object.keys(await ledger())).not.toContain(mcpJson()); }); + + describe('written by an older teamai, under a mapping only an earlier teamai.yaml made', () => { + const commitTeamYaml = (toolPaths: object): void => { + // JSON is YAML. + fse.writeFileSync(path.join(repoPath, 'teamai.yaml'), JSON.stringify({ team: 't', toolPaths })); + git(repoPath, 'add', '-A'); + git(repoPath, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'toolPaths'); + }; + const sidecarState = async (): Promise<{ files: Record; earlierMappingsRead?: true }> => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + return readResolvedMcpFiles(projectConfig); + }; + + beforeEach(async () => { + git(repoPath, 'init', '-q'); + commitTeamYaml(shared); + commitTeamYaml(TOOL_PATHS); + // What a teamai from before managed-mcp-files.json leaves: no record of the path, nothing in the exclude. + // The manifest keeps its resolved notes, so only the moved tool's server can hold the line. + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + }); + + it('is listed, and recorded for that tool, by the first pull on this version', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.mcp\.json/); + expect((await sidecarState()).files[mcpJson()]).toEqual({ tools: ['cursor'] }); + }); + + it('keeps its line on the pulls after, from the record', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('adds that tool to the record the file already has', async () => { + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + await trackResolvedMcpFiles(projectConfig, [{ tool: 'claude', file: mcpJson() }]); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect((await sidecarState()).files[mcpJson()]).toEqual({ tools: ['claude', 'cursor'] }); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('leaves it to the tools mapping it when only their servers are left', async () => { + await setServers({}); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + expect(Object.keys((await sidecarState()).files)).not.toContain(mcpJson()); + expect((await sidecarState()).earlierMappingsRead).toBe(true); + }); + + it('leaves a file the same tool still maps to that tool\'s own rules', async () => { + const { earlierMappedMcpTargets } = await import('../mcp-reconcile.js'); + const today = { ...teamConfig, toolPaths: shared } as TeamaiConfig; + + const found = await earlierMappedMcpTargets(projectConfig, await resolveMcpTargets(today, projectConfig, { includeUndetected: true })); + + expect(found?.map((target) => target.file)).not.toContain(mcpJson()); + const moved = await earlierMappedMcpTargets(projectConfig, await resolveMcpTargets(teamConfig, projectConfig, { includeUndetected: true })); + expect(moved?.map(({ tool, file }) => ({ tool, file }))).toContainEqual({ tool: 'cursor', file: mcpJson() }); + }); + }); }); it('lists the config in .git/info/exclude before writing the value into it', async () => { diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts index b524170a7..cb961c7de 100644 --- a/src/__tests__/mcp-resolved-files.test.ts +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -233,5 +233,29 @@ describe('managed-mcp-files.json', () => { expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [custom()]: { tools: ['claude'] } }); }); + + it('adds a tool another now maps the file for to its record while it holds what teamai may have written for that tool', async () => { + await fse.outputJson(sidecar, { version: 1, files: { [custom()]: { tools: ['claude'] } } }); + const other = path.join(tmp, 'project', '.mcp.json'); + const state = { kind: 'parsed', servers: ['open', 'jira'] } as const; + + await settleResolvedMcpFiles(cfg, [ + { file: custom(), tool: 'cursor', state, holding: true, owned: ['open'], remapped: true }, + { file: other, tool: 'cursor', state, holding: true, owned: ['open'], remapped: true }, + ]); + + expect((await readResolvedMcpFiles(cfg)).files).toEqual({ [custom()]: { tools: ['claude', 'cursor'] }, [other]: { tools: ['cursor'] } }); + }); + + it('adds a tool git tracks the file for to its record, marked tracked, and forgets it only once git no longer tracks it', async () => { + await fse.outputJson(sidecar, { version: 1, files: { [custom()]: { tools: ['claude'] } } }); + + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'cursor', state: { kind: 'parsed', servers: ['jira'] }, holding: false, owned: [], tracked: true }]); + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'claude', state: { kind: 'missing' }, holding: false, owned: [] }]); + expect((await readResolvedMcpFiles(cfg)).files[custom()]).toEqual({ tools: ['claude', 'cursor'], tracked: true }); + + await settleResolvedMcpFiles(cfg, [{ file: custom(), tool: 'cursor', state: { kind: 'missing' }, holding: false, owned: [], tracked: false }]); + expect(Object.keys((await readResolvedMcpFiles(cfg)).files)).not.toContain(custom()); + }); }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 605c795be..72eebd668 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -574,9 +574,13 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise null) ?? []; - for (const { tracked, ...target } of earlier) { + for (const { tracked, mappedBy, ...target } of earlier) { + if (tracked || holding.has(target.file)) continue; vars ??= await buildVarTable(localConfig); - if (!tracked && !holding.has(target.file) && await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired)) await hold(target.file); + manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; + const owned = mappedBy.length === 0 ? undefined + : mappedBy.flatMap((tool) => manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + if (await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired, owned)) await hold(target.file); } } if (holding.size === 0) return []; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 66cf73439..1d0a7fb56 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -618,9 +618,10 @@ const EARLIER_BUILTIN_MCP_PROJECT = { /** * The files earlier revisions of the team's teamai.yaml mapped a tool's * project MCP config to (`toolPaths..mcpProject`) that exist under the - * project root, and that no target in `known` and no file `cfg`'s worktree - * recorded is (#882), each saying whether git tracks it (no exclude line - * applies to one it does): a teamai from before managed-mcp-files.json may have + * project root, and that neither the tool's own target in `known` nor a file + * `cfg`'s worktree recorded for the tool is (#882), each saying whether git + * tracks it (no exclude line applies to one it does) and which other tools' + * targets in `known` reach it: a teamai from before managed-mcp-files.json may have * written a resolved value there, under a mapping the team changed before * this member's first pull on a teamai that records one, plus those under a * built-in default teamai has since changed. Read from the team @@ -631,7 +632,7 @@ const EARLIER_BUILTIN_MCP_PROJECT = { export async function earlierMappedMcpTargets( cfg: LocalConfig, known: McpTarget[], -): Promise | null> { +): Promise | null> { const { projectRoot } = cfg; if (!projectRoot) return []; const repoPath = cfg.repo.localPath; @@ -643,10 +644,12 @@ export async function earlierMappedMcpTargets( return null; } const root = await realFilePath(projectRoot); - const reached = new Set(await Promise.all( - [...known.map((target) => target.file), ...Object.keys((await readResolvedMcpFiles(cfg)).files)].map(realFilePath), - )); - const found = new Map(); + // Each path, by real path, with the tools today's targets or the record reach it for. + const mapped = await Promise.all(known.map(async ({ tool, file }) => ({ tool, real: await realFilePath(file) }))); + const recorded = await Promise.all(Object.entries((await readResolvedMcpFiles(cfg)).files) + .flatMap(([file, { tools }]) => tools.map(async (tool) => ({ tool, real: await realFilePath(file) })))); + const reached = (tool: string, real: string): boolean => [...mapped, ...recorded].some((r) => r.tool === tool && r.real === real); + const found = new Map(); for (const revision of [null, ...revisions]) { let toolPaths: unknown = EARLIER_BUILTIN_MCP_PROJECT; if (revision !== null) { @@ -667,8 +670,9 @@ export async function earlierMappedMcpTargets( const real = await realFilePath(file); const inside = path.relative(root, real); if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue; - if (reached.has(real) || !await pathExists(file)) continue; - found.set(key, { tool, format, file, projectScope: true, tracked: (await gitTracks(file)).kind === 'tracked' }); + if (reached(tool, real) || !await pathExists(file)) continue; + const mappedBy = [...new Set(mapped.filter((r) => r.real === real).map((r) => r.tool))]; + found.set(key, { tool, format, file, projectScope: true, tracked: (await gitTracks(file)).kind === 'tracked', mappedBy }); } } return [...found.values()]; @@ -713,15 +717,17 @@ export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: read * Why a file `earlierMappedMcpTargets` returned may hold a value an older * teamai resolved, or null: judged as a recorded file is, since the * manifest's records for its tool describe the file today's mapping reaches, - * not this one, plus the value scan. + * not this one, plus the value scan. `owned`: for one other tools' targets + * reach today, the servers their manifest records say they wrote there. */ export async function earlierMappedMcpFileEvidence( target: McpTarget, teamDefs: McpServerDef[] | null, vars: Record, ctx: () => Promise, + owned?: readonly string[], ): Promise { - return await recordedMcpFileEvidence([target]) ?? await resolvedValueEvidence(target, teamDefs, { owned: [] }, vars, ctx); + return await recordedMcpFileEvidence([target], owned) ?? await resolvedValueEvidence(target, teamDefs, { owned: [] }, vars, ctx); } /** @@ -1009,11 +1015,21 @@ async function protectProjectMcpConfigs( log.debug(`Did not read the MCP configs earlier toolPaths mappings reach: ${e instanceof Error ? e.message : String(e)}`); return null; }); - for (const { tracked, ...target } of earlier ?? []) { + // Held through the release, which reads only what was recorded before this run. + const found: string[] = []; + for (const { tracked, mappedBy, ...target } of earlier ?? []) { const state = await mcpFileState([target]); // No line protects a file git tracks: recorded as tracked, whatever it holds, and judged once git no longer tracks it. - if (tracked) observations.push({ file: target.file, tool: target.tool, state, holding: false, owned: [], tracked }); - else observations.push({ file: target.file, tool: target.tool, state, holding: await earlierMappedMcpFileEvidence(target, teamDefs, vars, ctx) !== null, owned: [] }); + if (tracked) { + observations.push({ file: target.file, tool: target.tool, state, holding: false, owned: [], tracked }); + continue; + } + // In a file other tools map today, their records tell their own servers. + const owned = mappedBy.length === 0 ? undefined + : mappedBy.flatMap((tool) => manifest[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + const holding = await earlierMappedMcpFileEvidence(target, teamDefs, vars, ctx, owned) !== null; + if (holding) found.push(target.file); + observations.push({ file: target.file, tool: target.tool, state, holding, owned: owned ?? [], ...owned ? { remapped: true as const } : {} }); } const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); @@ -1025,7 +1041,7 @@ async function protectProjectMcpConfigs( const exclusion = exclusions.get(file); return !holding.has(file) && !written.has(file) && exclusion?.kind === 'excluded' && exclusion.added; }); - await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before); + await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before, found); // After the release, which reads the files recorded before this run; also lists one an older teamai wrote. await settleRecordedMcpConfigs(localConfig, observations, { earlierMappingsRead: !earlierMappingsRead && earlier !== null }); } @@ -1056,6 +1072,8 @@ export async function releaseCleanMcpGitExcludes(teamConfig: TeamaiConfig, local * Remove each line of teamai's block whose files are all proven clean or in * `addedNow`: files this run listed and holds no evidence for, whose line it * takes back out even when they cannot be proven clean (one that does not parse). + * A line of a file in `kept` stays: this run found it holding by a record it + * has not written yet. */ async function releaseMcpGitExcludes( teamConfig: TeamaiConfig, @@ -1063,6 +1081,7 @@ async function releaseMcpGitExcludes( projectRoot: string, addedNow: string[], before?: ManagedMcpManifest, + kept: string[] = [], ): Promise { const dirs = [projectRoot]; for (const target of await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true })) dirs.push(path.dirname(target.file)); @@ -1071,6 +1090,7 @@ async function releaseMcpGitExcludes( if (excludes.size === 0) return; // Keyed as findMcpGitExcludes keys them: by real path (macOS /var). const exempt = new Set(await Promise.all(addedNow.map(realFilePath))); + const keep = new Set(await Promise.all(kept.map(realFilePath))); const held = await mcpConfigsNotProvenClean( teamConfig, localConfig, @@ -1078,7 +1098,7 @@ async function releaseMcpGitExcludes( { before, otherWorktrees: 'empty' }, ); for (const [excludeFile, entries] of excludes) { - const cleanEntries = entries.filter((entry) => entry.files.every((file) => !held.has(file) || exempt.has(file))); + const cleanEntries = entries.filter((entry) => entry.files.every((file) => (!held.has(file) || exempt.has(file)) && !keep.has(file))); const clean = cleanEntries.map((entry) => entry.pattern); if (clean.length === 0) continue; const result = await removeMcpGitExclude(excludeFile, clean); diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts index 0f38d4484..bb5a0b1f8 100644 --- a/src/mcp-resolved-files.ts +++ b/src/mcp-resolved-files.ts @@ -48,7 +48,7 @@ export interface McpFileObservation { owned: string[]; /** Whether git tracks it, for a file recorded (or to record) as one it tracked: kept, whatever it holds, while git does. */ tracked?: boolean; - /** `tool` no longer maps the file, another tool does: `holding` says whether it holds what teamai may have written for `tool`. */ + /** `tool` does not map the file today, another tool does: `holding` says whether it holds what teamai may have written for `tool`. */ remapped?: true; } @@ -160,11 +160,13 @@ export function recordUnverifiedMcpServers(cfg: LocalConfig, found: Array<{ file /** * Bring the record up to date with what the files hold: forget a file that is * gone or holds no server, record one holding a resolved value it did not - * list (written by an older teamai), take a tool off a file another tool now - * maps once the file holds nothing teamai may have written for it, and drop a - * noted server that left its file or that teamai owns again. A file that does - * not parse stays as it is, and so does one git tracks that was recorded as - * tracked: a checkout brings back what git holds. + * list (written by an older teamai), keep a tool on the record of a file + * another tool now maps while the file holds what teamai may have written for + * it (adding it for one an older teamai wrote), and take it off after, and + * drop a noted server that left its file or that teamai owns again. A file + * that does not parse stays as it is, and so does one recorded as tracked + * until an observation says git no longer tracks it: a checkout brings back + * what git holds. A tool found in a file git tracks is added, marked tracked. * `earlierMappingsRead`: the observations cover the files earlier revisions * of teamai.yaml mapped, which later pulls need not read again. */ @@ -180,21 +182,29 @@ export function settleResolvedMcpFiles( for (const { file, tool, state, holding, owned, tracked, remapped } of observations) { const entry = files[file]; if (tracked === true) { - if (!entry) files[file] = { tools: [tool], tracked: true }; - changed ||= !entry; + if (entry?.tools.includes(tool)) continue; + files[file] = entry ? { ...entry, tools: [...entry.tools, tool], tracked: true } : { tools: [tool], tracked: true }; + changed = true; continue; } if (state.kind === 'missing' || (state.kind === 'parsed' && state.servers.length === 0)) { - if (entry) delete files[file]; - changed ||= entry !== undefined; + const forget = entry !== undefined && (entry.tracked !== true || tracked === false); + if (forget) delete files[file]; + changed ||= forget; continue; } if (entry?.tracked === true && tracked === false) { delete entry.tracked; changed = true; } + if (remapped && holding) { + if (entry?.tools.includes(tool)) continue; + files[file] = entry ? { ...entry, tools: [...entry.tools, tool] } : { tools: [tool] }; + changed = true; + continue; + } if (remapped) { - if (holding || !entry?.tools.includes(tool)) continue; + if (!entry?.tools.includes(tool)) continue; const tools = entry.tools.filter((t) => t !== tool); if (tools.length > 0 || entry.unverified) files[file] = { ...entry, tools }; else delete files[file]; From c4d39c1119a9e460ee5dedc64d42a2b0ced78050 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 13:11:09 +0200 Subject: [PATCH 48/85] docs(mcp): describe the history read's configs another tool maps today (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f7d75d212..629c51c44 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1160,7 +1160,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path, and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere, that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, or found in that history), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 1c92e73d3..e00e2613c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1063,7 +1063,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径,在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录,或在上述历史中找到)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index e0e5c3409..f74b94590 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -53,12 +53,13 @@ once its file no longer holds a resolved value; `teamai uninstall` does so in every worktree. A file written under a `toolPaths..mcpProject` the team later changes or removes stays listed until it is deleted or holds no server; for one an older teamai wrote, the first pull finds the path in the team repo's -history of `teamai.yaml`, or among the built-in paths teamai has since changed, -and lists it while it holds any server; one git tracks is recorded instead and -listed once the member runs `git rm --cached` on it. `teamai doctor` checks -those paths until that pull. A file written for a tool the team moved elsewhere, -that another tool still maps, stays listed while it holds a server that tool did -not write, one of the member's own included. +history of `teamai.yaml`, or among the built-in paths teamai has since changed +(not one the same tool maps today), and lists it while it holds any server; one +git tracks is recorded instead and listed once the member runs `git rm --cached` +on it. `teamai doctor` checks those paths until that pull. A file written for a +tool the team moved elsewhere (recorded, or found in that history), that another +tool still maps, stays listed while it holds a server that tool did not write, +one of the member's own included. ## Invite a member From 3bea84433731cf85f41cf4e7be7cb64c716d5478 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 14:00:13 +0200 Subject: [PATCH 49/85] fix(mcp): prove a shared config clean only while every tool that wrote a resolved value there has its record (#882) --- src/__tests__/mcp-reconcile.test.ts | 36 +++++++++++++++++++++++++++++ src/mcp-reconcile.ts | 16 +++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 7a73a4b99..5681709d3 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -911,6 +911,42 @@ servers: vi.stubEnv('SECRET_TOKEN', 'super-secret-value'); }); + describe('a config two tools share (Claude and CodeBuddy on .mcp.json)', () => { + const shared = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } } } as TeamaiConfig; + const open = ' - name: open\n transport: http\n url: https://example.com/open\n'; + + it('keeps its line while a tool that wrote a resolved value there has lost its record, though the other tool\'s is intact', async () => { + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codebuddy]\n${open} tools: [claude]\n`); + await reconcileMcpForConfig(shared, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record; + delete manifest['codebuddy:project']; + await fse.writeJson(manifestFile, manifest); + await writeMcpYaml(`servers:\n${open} tools: [claude]\n`); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared, projectConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('releases its line once clean when only one of them ever wrote a resolved value there', async () => { + await writeMcpYaml(`${withSecret} tools: [claude]\n${open}`); + await reconcileMcpForConfig(shared, projectConfig); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + await writeMcpYaml(`servers:\n${open}`); + + await reconcileMcpForConfig(shared, projectConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).not.toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + }); + }); + it('adds every such config to .git/info/exclude once, inside a teamai block', async () => { await writeMcpYaml(withSecret); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 1d0a7fb56..439c325c8 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -832,7 +832,10 @@ export async function mcpConfigsNotProvenClean( const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). - const targets = new Map(); + const targets = new Map; proven: Set; writers: Set; + }>(); const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); const ownRoot = await realRoot(localConfig.projectRoot); @@ -855,12 +858,21 @@ export async function mcpConfigsNotProvenClean( // A rebuilt record whose file's other servers could not be noted says nothing of them yet. const recorded = Array.isArray(records) && !records.some((record) => record.unnoted); // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. + // It counts as recorded only while every tool managed-mcp-files.json says wrote a resolved value + // there still has its record: another tool's intact one proves nothing of that tool's entries. + // (A writer that no longer maps the file is judged by the remapped rule below.) const seen = targets.get(key); + const mappers = new Set([...seen?.mappers ?? [], target.tool]); + const proven = new Set([...seen?.proven ?? [], ...recorded ? [target.tool] : []]); + const writers = new Set([...seen?.writers ?? [], ...ledger[target.file]?.tools ?? []]); targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], unverified: [...seen?.unverified ?? [], ...ledger[target.file]?.unverified ?? []], - recorded: recorded || seen?.recorded === true, + recorded: proven.size > 0 && [...writers].every((tool) => proven.has(tool) || !mappers.has(tool)), + mappers, + proven, + writers, foreign: foreign || seen?.foreign === true, }); } From 3d718b8a2dc19699f3a5a8af3c24b33d552935f4 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 14:46:41 +0200 Subject: [PATCH 50/85] fix(mcp): judge a built-in location no mapping reaches today as an earlier-mapped file, and a shared config no pull recorded by every tool mapping it (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 36 ++++++ src/__tests__/doctor.test.ts | 6 +- src/__tests__/mcp-reconcile.test.ts | 146 ++++++++++++++++++++++ src/doctor-delivery.ts | 27 ++-- src/mcp-reconcile.ts | 96 +++++++++++--- 5 files changed, 282 insertions(+), 29 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 56c5a643d..1cbb12b21 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -431,6 +431,42 @@ describe('doctor — MCP servers delivered on disk', () => { }); }); + describe('a tool\'s built-in location, once the team moved the tool, and its records describe the file it maps now', () => { + const old = (): string => path.join(projectRoot, '.cursor', 'mcp.json'); + + beforeEach(async () => { + teamConfig.toolPaths = { + claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, + cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: '.cursor/team-mcp.json' }, + }; + // Its server left mcp.yaml since. + await fse.outputJson(old(), { + mcpServers: { gone: { type: 'http', url: 'https://gone.example/mcp', headers: { Authorization: 'Bearer t0ken' } } }, + }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], + [managedMcpManifestKey('cursor', true)]: [], + }); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + }); + + it('fails, naming it once', async () => { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(old())).toHaveLength(2); + expect(check.fix).not.toContain(path.join(projectRoot, '.mcp.json')); + }); + + it('passes once it is kept out of git', async () => { + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.cursor/mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(true); + }); + }); + describe('a config written for a tool the team has since moved, that another tool\'s mapping still reaches', () => { const file = (): string => path.join(projectRoot, '.mcp.json'); diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index c5e1685fd..760d4fe2d 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -356,7 +356,11 @@ describe('doctor — hook checks', () => { copilot: { hooks: '.github/hooks/teamai.json' }, }, }); - mockedPathExists.mockImplementation(async (filePath: string) => filePath !== copilotHome); + // No project MCP config exists: one at a tool's built-in location that cannot be read would fail the git exclude check. + const { TeamaiConfigSchema } = await import('../types.js'); + const mcpConfigs = Object.values(TeamaiConfigSchema.shape.toolPaths.parse(undefined)) + .flatMap((paths) => paths.mcpProject ? [path.join(projectRoot, paths.mcpProject)] : []); + mockedPathExists.mockImplementation(async (filePath: string) => filePath !== copilotHome && !mcpConfigs.includes(filePath)); let allPassed: boolean; try { diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 5681709d3..70f71b552 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -945,6 +945,45 @@ servers: expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).not.toContain('super-secret-value'); expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); }); + + describe('without managed-mcp-files.json, as an install from before it has none', () => { + const withoutSidecar = async (): Promise => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + }; + + it('keeps its line while a tool that wrote a resolved value there has lost its record, though the other tool\'s is intact', async () => { + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codebuddy]\n${open} tools: [claude]\n`); + await reconcileMcpForConfig(shared, projectConfig); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record; + delete manifest['codebuddy:project']; + await fse.writeJson(manifestFile, manifest); + await withoutSidecar(); + await writeMcpYaml(`servers:\n${open} tools: [claude]\n`); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared, projectConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + // Nothing says which of the two wrote there, so a tool mapping it with no record at all holds it too. + it('keeps its line while a tool mapping it has no record, until a pull records the file', async () => { + await writeMcpYaml(`${withSecret} tools: [claude]\n${open}`); + await reconcileMcpForConfig(shared, projectConfig); + await withoutSidecar(); + await writeMcpYaml(`servers:\n${open}`); + + await reconcileMcpForConfig(shared, projectConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).not.toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + }); }); it('adds every such config to .git/info/exclude once, inside a teamai block', async () => { @@ -1541,6 +1580,113 @@ servers: }); }); + describe('a tool\'s built-in location, once the team moves or drops the tool', () => { + const moved = { ...teamConfig, toolPaths: { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } } } as TeamaiConfig; + const dropped = { ...teamConfig, toolPaths: { claude: TOOL_PATHS.claude, codebuddy: TOOL_PATHS.codebuddy } } as TeamaiConfig; + const cursorJson = (): string => path.join(projectRoot, '.cursor', 'mcp.json'); + const open = 'servers:\n - name: open\n transport: http\n url: https://example.com/open\n'; + const ledger = async (): Promise> => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + return (await readResolvedMcpFiles(projectConfig)).files; + }; + // What a teamai from before managed-mcp-files.json leaves: no record of the path, nothing in the exclude. + const asOlderTeamai = async (): Promise => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record>>; + await fse.writeJson(manifestFile, Object.fromEntries(Object.entries(manifest).map(([key, records]) => + [key, records.map(({ name, hash }) => ({ name, hash }))]))); + }; + + beforeEach(async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + // Then the team drops that server; the token is no longer set. + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + }); + + // The pull that writes the moved file replaces Cursor's records: from the next one they describe that file. + it('keeps the line a pull on this version added when the team moves the tool', async () => { + await reconcileMcpForConfig(moved, projectConfig); + await reconcileMcpForConfig(moved, projectConfig); + + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.cursor\/mcp\.json/); + }); + + it('is listed and recorded by the first pull on this version when an older teamai wrote it', async () => { + await asOlderTeamai(); + + await reconcileMcpForConfig(moved, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.cursor\/mcp\.json/); + expect((await ledger())[cursorJson()]).toEqual({ tools: ['cursor'] }); + }); + + it('keeps its line on the pulls after, from the record', async () => { + await asOlderTeamai(); + await reconcileMcpForConfig(moved, projectConfig); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(moved, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + + it('keeps its line when the team then drops the tool', async () => { + await asOlderTeamai(); + await reconcileMcpForConfig(moved, projectConfig); + await asOlderTeamai(); + + await reconcileMcpForConfig(dropped, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + + // No record describes that path any more, so a server of the member's own cannot be told from an older teamai's. + it.each([ + ['moves', moved], + ['drops', dropped], + ])('lists and records it while it holds only a server of the member\'s own, when the team %s the tool', async (_label, config) => { + await reconcileMcpForConfig(moved, projectConfig); + await fse.writeJson(cursorJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + await asOlderTeamai(); + + await reconcileMcpForConfig(config, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect((await ledger())[cursorJson()]).toEqual({ tools: ['cursor'] }); + }); + + it('lets its line go, and forgets it, once it holds no server', async () => { + await reconcileMcpForConfig(moved, projectConfig); + await fse.writeJson(cursorJson(), { mcpServers: {} }); + + await reconcileMcpForConfig(moved, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/\.cursor\/mcp\.json/); + expect(Object.keys(await ledger())).not.toContain(cursorJson()); + }); + + // CodeBuddy's built-in location is .mcp.json, which Claude maps; TOOL_PATHS moves CodeBuddy. + it('leaves one another tool maps today to that tool\'s rules', async () => { + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + expect((await ledger())[path.join(projectRoot, '.mcp.json')]).toEqual({ tools: ['claude'] }); + }); + }); + it('lists the config in .git/info/exclude before writing the value into it', async () => { await writeMcpYaml(withSecret); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index ef6a0a5dd..9f724386c 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -560,7 +560,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise !unmapped.has(target)); for (const target of targets) { if (holding.has(target.file) || !await pathExists(target.file)) continue; manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; @@ -606,16 +609,16 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise null) ?? []; - for (const { tracked, mappedBy, ...target } of earlier) { - if (tracked || holding.has(target.file)) continue; - vars ??= await buildVarTable(localConfig); - manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; - const owned = mappedBy.length === 0 ? undefined - : mappedBy.flatMap((tool) => manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); - if (await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired, owned)) await hold(target.file); - } + // A built-in location no mapping reaches today, which no record covers, is judged as one of them. + const earlier = (await readResolvedMcpFiles(localConfig)).earlierMappingsRead ? [] + : await earlierMappedMcpTargets(localConfig, mapped).catch(() => null) ?? []; + for (const { tracked, mappedBy, ...target } of [...earlier, ...await unrecordedUnmappedMcpDefaults(localConfig, unmapped, targets)]) { + if (tracked || holding.has(target.file)) continue; + vars ??= await buildVarTable(localConfig); + manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; + const owned = mappedBy.length === 0 ? undefined + : mappedBy.flatMap((tool) => manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + if (await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired, owned)) await hold(target.file); } if (holding.size === 0) return []; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 439c325c8..94557495a 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -225,6 +225,11 @@ export interface McpTarget { /** Absolute path of the config file to edit. */ file: string; projectScope: boolean; + /** + * Added by `includeUndetected`: the built-in location of a tool the team maps + * elsewhere or not at all. No mapping of today's reaches it for this tool. + */ + builtinFallback?: true; } /** @@ -251,13 +256,13 @@ export async function resolveMcpTargets( // Skills/settings/agents probe paths must reflect the active scope: OpenCode's // user-scope resources live under ~/.config/opencode, not ~/.opencode. const toolPaths = scopedToolPaths(teamConfig, localConfig); - const entries = Object.entries(toolPaths); + const entries: Array<[string, (typeof toolPaths)[string], boolean?]> = Object.entries(toolPaths); if (options.includeUndetected && projectScope) { for (const [tool, paths] of Object.entries(TeamaiConfigSchema.shape.toolPaths.parse(undefined))) { - if (paths.mcpProject && toolPaths[tool]?.mcpProject !== paths.mcpProject) entries.push([tool, paths]); + if (paths.mcpProject && toolPaths[tool]?.mcpProject !== paths.mcpProject) entries.push([tool, paths, true]); } } - for (const [tool, paths] of entries) { + for (const [tool, paths, builtinFallback] of entries) { const format = detectMcpFormat(tool); if (!format) continue; @@ -279,11 +284,50 @@ export async function resolveMcpTargets( continue; } - targets.push({ tool, format, file, projectScope }); + targets.push({ tool, format, file, projectScope, ...builtinFallback ? { builtinFallback: true as const } : {} }); } return targets; } +/** + * The built-in fallbacks among `targets` no current mapping of any tool + * reaches (#882): the team moved or dropped their tool, so its manifest + * records describe another file, or none, while an earlier pull may have + * written this one. One another tool maps today is left to that tool's rules. + */ +export async function unmappedMcpDefaults(targets: McpTarget[]): Promise> { + const mappedNow = await Promise.all(targets.filter((t) => !t.builtinFallback).map((t) => realFilePath(t.file))); + const unmapped = new Set(); + for (const target of targets) { + if (target.builtinFallback && !mappedNow.includes(await realFilePath(target.file))) unmapped.add(target); + } + return unmapped; +} + +/** + * The files of `unmapped` (`unmappedMcpDefaults`) that exist and `cfg`'s + * worktree has not recorded for their tool, as `earlierMappedMcpTargets` + * returns its files: judged as one an earlier mapping reached. `known`: the + * other targets. + */ +export async function unrecordedUnmappedMcpDefaults( + cfg: LocalConfig, + unmapped: Iterable, + known: McpTarget[], +): Promise> { + const reach = await Promise.all(known.map(async ({ tool, file }) => ({ tool, real: await realFilePath(file) }))); + const recorded = await Promise.all(Object.entries((await readResolvedMcpFiles(cfg)).files) + .flatMap(([file, { tools }]) => tools.map(async (tool) => ({ tool, real: await realFilePath(file) })))); + const found: Array = []; + for (const target of unmapped) { + const real = await realFilePath(target.file); + if (recorded.some((r) => r.tool === target.tool && r.real === real) || !await pathExists(target.file)) continue; + const mappedBy = [...new Set(reach.filter((r) => r.real === real && r.tool !== target.tool).map((r) => r.tool))]; + found.push({ ...target, tracked: (await gitTracks(target.file)).kind === 'tracked', mappedBy }); + } + return found; +} + // ─── JSON target I/O ───────────────────────────────────────── export interface JsonDoc { @@ -805,14 +849,16 @@ async function readProjectMcpManifest(cfg: LocalConfig, projectRoot: string): Pr * repository's linked worktree, read as the file of its line this project maps * is, that parses and holds none, and one a worktree recorded writing a * resolved value to under a toolPaths mapping since changed (managed-mcp-files.json) - * that parses and holds none. One a tool reads holding servers is clean only when its worktree's manifest + * that parses and holds none, as is a tool's built-in location no mapping reaches today. One a tool reads + * holding servers is clean only when its worktree's manifest * records what teamai wrote to that tool's file (an empty list once teamai took * its last server out), and the file holds none of the team's servers that need * a resolved `${VAR}` there, none of teamai's own entries the manifest records * and cleanup left (their definition may have left mcp.yaml), and none of the * values of the variables set in this environment. Anything else (no tool reads * it, it does not parse, the team's servers cannot be read, the manifest is - * lost, empty, does not parse, has no record for the tool, or a record rebuilt + * lost, empty, does not parse, has no record for the tool (for a file + * managed-mcp-files.json does not list, for any tool mapping it today), or a record rebuilt * without noting the file's other servers in managed-mcp-files.json) is not: a server * teamai wrote, since dropped from mcp.yaml, with a value no longer set, looks * like the member's own. @@ -834,12 +880,14 @@ export async function mcpConfigsNotProvenClean( // Keyed by real path: the protected paths come from git, which resolves symlinks (macOS /var). const targets = new Map; proven: Set; writers: Set; + mappers: Set; mapsToday: Set; proven: Set; writers: Set; }>(); const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); const ownRoot = await realRoot(localConfig.projectRoot); const recordedBy = new Map(); + // A built-in location no mapping reaches today, in each worktree: judged as a file an earlier mapping reached. + const unmappedBy = new Map(); for (const cfg of await projectWorktreeConfigs(localConfig)) { const manifest = cfg === localConfig && before ? before : cfg.projectRoot ? await readProjectMcpManifest(cfg, cfg.projectRoot) @@ -849,10 +897,14 @@ export async function mcpConfigsNotProvenClean( const cfgTargets: McpTarget[] = []; recordedBy.set(cfg, cfgTargets); const { files: ledger } = await readResolvedMcpFiles(cfg); + const unmapped = [...await unmappedMcpDefaults(await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true }))]; + unmappedBy.set(cfg, unmapped); for (const target of await resolveMcpTargets(teamConfig, cfg, { includeUndetected: true })) { const dir = await fse.realpath(path.dirname(target.file)).catch(() => path.dirname(target.file)); const key = path.join(dir, path.basename(target.file)); cfgTargets.push(target); + // Judged below, as a file an earlier mapping reached. + if (unmapped.some((t) => t.tool === target.tool && t.file === target.file)) continue; const records = manifest[managedMcpManifestKey(target.tool, true)]; const owned = Array.isArray(records) ? records : []; // A rebuilt record whose file's other servers could not be noted says nothing of them yet. @@ -860,17 +912,22 @@ export async function mcpConfigsNotProvenClean( // One file reached twice (two tools share it, or a checkout through a symlink) merges what each says. // It counts as recorded only while every tool managed-mcp-files.json says wrote a resolved value // there still has its record: another tool's intact one proves nothing of that tool's entries. - // (A writer that no longer maps the file is judged by the remapped rule below.) + // (A writer that no longer maps the file is judged by the remapped rule below.) With no such list + // (a file no pull on this version recorded), every tool whose mapping reaches it today needs one. const seen = targets.get(key); const mappers = new Set([...seen?.mappers ?? [], target.tool]); + const mapsToday = new Set([...seen?.mapsToday ?? [], ...target.builtinFallback ? [] : [target.tool]]); const proven = new Set([...seen?.proven ?? [], ...recorded ? [target.tool] : []]); const writers = new Set([...seen?.writers ?? [], ...ledger[target.file]?.tools ?? []]); targets.set(key, { target, owned: [...seen?.owned ?? [], ...owned], unverified: [...seen?.unverified ?? [], ...ledger[target.file]?.unverified ?? []], - recorded: proven.size > 0 && [...writers].every((tool) => proven.has(tool) || !mappers.has(tool)), + recorded: proven.size > 0 && (writers.size > 0 + ? [...writers].every((tool) => proven.has(tool) || !mappers.has(tool)) + : [...mapsToday].every((tool) => proven.has(tool))), mappers, + mapsToday, proven, writers, foreign: foreign || seen?.foreign === true, @@ -882,10 +939,12 @@ export async function mcpConfigsNotProvenClean( const recorded = new Map(); const remapped = new Map(); for (const [cfg, cfgTargets] of recordedBy) { - for (const [file, { targets: group }] of await recordedMcpTargets(cfg, cfgTargets)) { - const key = await realFilePath(file); - if (!targets.has(key)) recorded.set(key, group); - else remapped.set(key, [...remapped.get(key) ?? [], ...group]); + const groups = [...(await recordedMcpTargets(cfg, cfgTargets)).values()].map(({ targets: group }) => group); + for (const group of [...groups, ...[...unmappedBy.get(cfg) ?? []].map((target) => [target])]) { + const key = await realFilePath(group[0].file); + const map = targets.has(key) ? remapped : recorded; + const known = map.get(key) ?? []; + map.set(key, [...known, ...group.filter((t) => !known.some((k) => k.tool === t.tool && k.file === t.file))]); } } // Short values, paths and the login name turn up in ordinary configs, so they prove nothing. @@ -1016,8 +1075,9 @@ async function protectProjectMcpConfigs( const vars = await buildVarTable(localConfig); const ctx = once(() => buildDesiredMcpContext(teamConfig, localConfig)); const mapped = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); + const unmapped = await unmappedMcpDefaults(mapped); // Tried before its write this run, and reported there. - const targets = mapped.filter((target) => exclusions.get(target.file)?.kind !== 'failed'); + const targets = mapped.filter((target) => !unmapped.has(target) && exclusions.get(target.file)?.kind !== 'failed'); const { files: ledger, earlierMappingsRead } = await readResolvedMcpFiles(localConfig); const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null; @@ -1027,9 +1087,11 @@ async function protectProjectMcpConfigs( log.debug(`Did not read the MCP configs earlier toolPaths mappings reach: ${e instanceof Error ? e.message : String(e)}`); return null; }); + // And on every pull, a built-in location no mapping reaches today that no record of this version covers yet. + const fallbacks = await unrecordedUnmappedMcpDefaults(localConfig, unmapped, mapped.filter((target) => !unmapped.has(target))); // Held through the release, which reads only what was recorded before this run. const found: string[] = []; - for (const { tracked, mappedBy, ...target } of earlier ?? []) { + for (const { tracked, mappedBy, ...target } of [...earlier ?? [], ...fallbacks]) { const state = await mcpFileState([target]); // No line protects a file git tracks: recorded as tracked, whatever it holds, and judged once git no longer tracks it. if (tracked) { @@ -1070,7 +1132,9 @@ export async function releaseCleanMcpGitExcludes(teamConfig: TeamaiConfig, local try { await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, []); const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); - const targets = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); + const mapped = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); + const unmapped = await unmappedMcpDefaults(mapped); + const targets = mapped.filter((target) => !unmapped.has(target)); await settleRecordedMcpConfigs(localConfig, await observeMcpConfigs(localConfig, targets, manifest, async () => false)); } catch (e) { log.warn( From da0c8f837e30a0aed712a867acf09940984efdc6 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 14:46:41 +0200 Subject: [PATCH 51/85] docs(mcp): describe the built-in location of a moved or dropped tool, and a shared config no pull recorded (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 6 +++++- skill-data/setup/references/uninstall.md | 5 +++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5dc4206fb..b73a97ffd 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1162,7 +1162,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, or dropped from `toolPaths` (at the tool's built-in location), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, or in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, or found in that history), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none, unless another tool maps that path today: then that tool's rules judge it), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, or found in that history), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 99e15a0ea..2985f94c7 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1065,7 +1065,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用、或已从 `toolPaths` 移除的工具写入的文件(按该工具的内置位置查找),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件,或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录,或在上述历史中找到)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;但若当前有另一个工具映射到该路径,则按那个工具的规则判断),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录,或在上述历史中找到)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index f74b94590..4eae4b493 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -59,7 +59,11 @@ git tracks is recorded instead and listed once the member runs `git rm --cached` on it. `teamai doctor` checks those paths until that pull. A file written for a tool the team moved elsewhere (recorded, or found in that history), that another tool still maps, stays listed while it holds a server that tool did not write, -one of the member's own included. +one of the member's own included. The built-in location of a tool the team drops +from `toolPaths` or moves elsewhere stays listed while it holds any server, +unless another tool maps that path today (CodeBuddy's `.mcp.json`, which Claude +maps), which then judges it. A file two tools map, with no pull on this version +having recorded it, needs a `managed-mcp.json` record from each of them. ## Invite a member diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index a13394d2a..4a680d8f0 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -60,8 +60,9 @@ and give it your team repo URL."* resolved `${VAR}` value. A line names the path a write lands in: for a config under a symlinked directory, the link's target (`/config/mcp.json` for `.cursor/` linking to `config/`). For one it cannot prove clean (including one written - under a `toolPaths` mapping since changed, or in a nested repository's linked - worktree, that still holds servers, and one written for a tool since moved that + under a `toolPaths` mapping since changed, at the built-in location of a tool + the team dropped or moved that no other tool maps, or in a nested repository's + linked worktree, that still holds servers, and one written for a tool since moved that another tool maps, holding a server that tool did not write) it keeps the line and warns, naming the file and why: have the user remove teamai's servers from that file, then delete the line (with the last one, the block's markers). Do not From 32fa951877593173a27164e8599a8b4d234eb55d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 15:30:11 +0200 Subject: [PATCH 52/85] fix(mcp): name the ignore rule that re-includes a config teamai just listed, instead of saying git tracks it (#882) --- src/__tests__/mcp-git-exclude.test.ts | 33 ++++++++++++++++++++++++++- src/mcp-git-exclude.ts | 26 ++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index 08ea3a8e7..aaae8ca11 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -11,12 +11,13 @@ vi.mock('../utils/logger.js', () => ({ // Git's own failure modes (unsafe repository, bad config) are hard to stage for one subcommand alone. const failCheckIgnore = vi.hoisted(() => ({ on: false })); const failLsFiles = vi.hoisted(() => ({ on: false })); +const failVerboseCheckIgnore = vi.hoisted(() => ({ on: false })); vi.mock('../utils/exec.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, execCommand: (cmd: string, args: string[], opts?: Parameters[2]) => - failCheckIgnore.on && args[0] === 'check-ignore' + (failCheckIgnore.on || (failVerboseCheckIgnore.on && args.includes('-v'))) && args[0] === 'check-ignore' ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository' }) : failLsFiles.on && args.includes('ls-files') ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: index file corrupt' }) @@ -55,6 +56,7 @@ describe('teamai block in .git/info/exclude (#882)', () => { afterEach(async () => { failCheckIgnore.on = false; failLsFiles.on = false; + failVerboseCheckIgnore.on = false; slowExcludeRead.on = false; vi.mocked(log.warn).mockClear(); await fse.remove(repo); @@ -193,6 +195,35 @@ describe('teamai block in .git/info/exclude (#882)', () => { }); }); + // A rule after teamai's line, or in a .gitignore, which git reads first, can re-include the file. + describe('for a file a rule of the member\'s re-includes', () => { + beforeEach(async () => { + await fse.writeFile(path.join(repo, '.gitignore'), 'node_modules/\n!/.mcp.json\n'); + }); + + it('names the rule, and says to remove it rather than untrack the file', async () => { + const file = path.join(repo, '.mcp.json'); + const gitignore = path.join(await fse.realpath(repo), '.gitignore'); + + expect(await ensureExcludedFromGit(file)).toEqual({ + kind: 'failed', + reason: `a rule in your git ignore files re-includes ${file}: \`!/.mcp.json\` (${gitignore}:2)`, + fix: `Remove \`!/.mcp.json\` from ${gitignore}, then run \`teamai pull\` again.`, + }); + }); + + it('says so when git cannot name the rule', async () => { + failVerboseCheckIgnore.on = true; + const file = path.join(repo, '.mcp.json'); + + expect(await ensureExcludedFromGit(file)).toEqual({ + kind: 'failed', + reason: `a rule in your git ignore files re-includes ${file}`, + fix: 'Remove the rule in .gitignore, .git/info/exclude or core.excludesFile that re-includes it (`git check-ignore -v` names it), then run `teamai pull` again.', + }); + }); + }); + it('stays quiet outside any repository', async () => { const outside = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-no-repo-')); await fse.writeJson(path.join(outside, '.mcp.json'), {}); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 3b9acdfd2..0efc7ad53 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -254,7 +254,31 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo }; } if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); - return (await gitTracking(file)).kind === 'would-commit' ? tracked() : { kind: 'excluded', added: result === 'written' }; + if ((await gitTracking(file)).kind !== 'would-commit') return { kind: 'excluded', added: result === 'written' }; + // Untracked, as checked above: a rule git reads after teamai's line, or before it, re-includes the file. + const named = await gitPathOf(file); + const rule = await reincludingRule(landed); + return rule + ? { + kind: 'failed', + reason: `a rule in your git ignore files re-includes ${named.label}: \`${rule.pattern}\` (${rule.source}:${rule.line})`, + fix: `Remove \`${rule.pattern}\` from ${rule.source}, then run \`teamai pull\` again.`, + } + : { + kind: 'failed', + reason: `a rule in your git ignore files re-includes ${named.label}`, + fix: 'Remove the rule in .gitignore, .git/info/exclude or core.excludesFile that re-includes it (`git check-ignore -v` names it), then run `teamai pull` again.', + }; +} + +/** The negated rule `git check-ignore -v` says decides `file`, or null when it names none. */ +async function reincludingRule(file: string): Promise<{ source: string; line: string; pattern: string } | null> { + const dir = await existingAncestor(file); + const result = await execCommand('git', ['check-ignore', '-v', '--', path.relative(dir, file)], { cwd: dir, timeoutMs: 10_000 }) + .catch(() => null); + // ::, the source as git names it from `dir`. + const match = result?.code === 0 ? /^(.*):(\d+):(!.*)\t/.exec(result.stdout) : null; + return match ? { source: path.resolve(dir, match[1]), line: match[2], pattern: match[3] } : null; } /** From cd69dad7dd170baad24151324ced600d3eede09a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 15:30:11 +0200 Subject: [PATCH 53/85] fix(mcp): judge a moved tool's built-in location another tool maps for that tool too, and hold a config's line while no managed-mcp.json claims its servers (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 48 ++++- src/__tests__/mcp-reconcile.test.ts | 236 ++++++++++++++++++---- src/__tests__/uninstall.test.ts | 18 +- src/doctor-delivery.ts | 6 +- src/mcp-reconcile.ts | 57 +++++- 5 files changed, 320 insertions(+), 45 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 1cbb12b21..f713a5eda 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -467,6 +467,38 @@ describe('doctor — MCP servers delivered on disk', () => { }); }); + // The toolPaths here drop CodeBuddy, whose built-in location is Claude's .mcp.json. + describe('a tool\'s built-in location another tool maps today, once the team moved or dropped the tool', () => { + const file = (): string => path.join(projectRoot, '.mcp.json'); + + beforeEach(async () => { + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + // Claude's own entry, and one an older teamai wrote there for CodeBuddy, whose record is lost. + await fse.writeJson(file(), { + mcpServers: { + docs: { type: 'http', url: 'https://docs.example/mcp' }, + gone: { type: 'http', url: 'https://gone.example/mcp', headers: { Authorization: 'Bearer t0ken' } }, + }, + }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'h', resolved: false }], + }); + }); + + it('fails, naming it once, while it holds a server none of the tools mapping it own', async () => { + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(file())).toHaveLength(2); + }); + + it('emits no check once only their servers are left', async () => { + await fse.writeJson(file(), { mcpServers: { docs: { type: 'http', url: 'https://docs.example/mcp' } } }); + + expect(await excludeCheck()).toBeUndefined(); + }); + }); + describe('a config written for a tool the team has since moved, that another tool\'s mapping still reaches', () => { const file = (): string => path.join(projectRoot, '.mcp.json'); @@ -574,8 +606,22 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await check.check()).toBe(false); }); - it('emits no check when the server of that name is the member\'s own, not teamai\'s', async () => { + it('fails, naming it, while this worktree has no managed-mcp.json and the file holds a server since dropped from mcp.yaml', async () => { + teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; await fse.remove(managedMcpManifestPath(getDataHome(localConfig), projectRoot)); + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); + }); + + it('emits no check when the server of that name is the member\'s own, not teamai\'s', async () => { + // CodeBuddy at its built-in .mcp.json: dropped, any server there Claude's records don't own would hold it. + teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; + // teamai owns nothing there. With no managed-mcp.json at all, any server would hold it. + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { [managedMcpManifestKey('claude', true)]: [] }); expect(await excludeCheck()).toBeUndefined(); }); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 70f71b552..8af81870e 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -54,6 +54,9 @@ const TOOL_PATHS = { codex: { skills: '.codex/skills', settings: '.codex/hooks.json', mcp: '.codex/config.toml' }, tclaude: { skills: '.tclaude/skills', settings: '.tclaude/settings.json', mcp: '.tclaude/.claude.json' }, }; +// CodeBuddy at its built-in .mcp.json, beside Claude. TOOL_PATHS moves it, which makes .mcp.json a location of +// CodeBuddy's that no record of it describes: any server there that Claude's records don't own holds a line (#882). +const UNMOVED_TOOL_PATHS = { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } }; describe('MCP reconcile', () => { let tmpDir: string; @@ -910,6 +913,13 @@ servers: projectConfig = { ...localConfig, scope: 'project', projectRoot } as unknown as LocalConfig; vi.stubEnv('SECRET_TOKEN', 'super-secret-value'); }); + const unmovedConfig = (): TeamaiConfig => ({ ...teamConfig, toolPaths: UNMOVED_TOOL_PATHS } as TeamaiConfig); + // A worktree an earlier pull ran in, holding nothing of teamai's. With no managed-mcp.json at all, + // any server in a config may be one teamai wrote, and the pull notes it (#882). + const pulledBefore = async (): Promise => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.outputJson(managedMcpManifestPath(getDataHome(projectConfig), projectRoot), { 'claude:project': [], 'cursor:project': [] }); + }; describe('a config two tools share (Claude and CodeBuddy on .mcp.json)', () => { const shared = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } } } as TeamaiConfig; @@ -1530,7 +1540,7 @@ servers: }); it('is listed, and recorded for that tool, by the first pull on this version', async () => { - await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(unmovedConfig(), projectConfig); expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); @@ -1551,7 +1561,7 @@ servers: const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); await trackResolvedMcpFiles(projectConfig, [{ tool: 'claude', file: mcpJson() }]); - await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(unmovedConfig(), projectConfig); expect((await sidecarState()).files[mcpJson()]).toEqual({ tools: ['claude', 'cursor'] }); expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); @@ -1675,15 +1685,88 @@ servers: expect(Object.keys(await ledger())).not.toContain(cursorJson()); }); - // CodeBuddy's built-in location is .mcp.json, which Claude maps; TOOL_PATHS moves CodeBuddy. - it('leaves one another tool maps today to that tool\'s rules', async () => { + }); + + // CodeBuddy's built-in location is .mcp.json, which Claude maps; TOOL_PATHS moves CodeBuddy to .codebuddy/mcp.json. + describe('a tool\'s built-in location another tool maps today, once the team moves or drops the tool', () => { + const builtin = (): TeamaiConfig => ({ ...teamConfig, toolPaths: { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } } } as TeamaiConfig); + const dropped = (): TeamaiConfig => ({ ...teamConfig, toolPaths: { claude: TOOL_PATHS.claude, cursor: TOOL_PATHS.cursor } } as TeamaiConfig); + const mcpJson = (): string => path.join(projectRoot, '.mcp.json'); + const open = ' - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'; + const ledger = async (): Promise> => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + return (await readResolvedMcpFiles(projectConfig)).files; + }; + // An older teamai: no record of the path, nothing in the exclude; and CodeBuddy's record is lost. + const asOlderTeamai = async (): Promise => { + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record; + delete manifest['codebuddy:project']; + await fse.writeJson(manifestFile, manifest); + }; + const setServers = async (servers: Record): Promise => { + const doc = await fse.readJson(mcpJson()) as { mcpServers: Record }; + await fse.writeJson(mcpJson(), { mcpServers: { open: doc.mcpServers.open, ...servers } }); + }; + + beforeEach(async () => { + // CodeBuddy's server, with the token, lands in .mcp.json beside Claude's. + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codebuddy]\n${open}`); + await reconcileMcpForConfig(builtin(), projectConfig); + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + // Then the team drops that server; the token is no longer set. + await writeMcpYaml(`servers:\n${open}`); + vi.stubEnv('SECRET_TOKEN', ''); + }); + + it.each([ + ['moves', () => teamConfig], + ['drops', dropped], + ])('lists it, and records it for that tool, when an older teamai wrote it and the team %s the tool', async (_label, config) => { + await asOlderTeamai(); + + await reconcileMcpForConfig(config(), projectConfig); + + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.mcp\.json/); + expect((await ledger())[mcpJson()]).toEqual({ tools: ['codebuddy'] }); + }); + + it('keeps its line on the pulls after, from the record', async () => { + await asOlderTeamai(); + await reconcileMcpForConfig(teamConfig, projectConfig); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + // The accepted cost: nothing tells a member's own server there from one teamai wrote for CodeBuddy. + it('keeps a line while it holds a server of the member\'s own', async () => { + await setServers({ mine: { type: 'http', url: 'https://mine.example/mcp' } }); + await asOlderTeamai(); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect((await ledger())[mcpJson()]).toEqual({ tools: ['codebuddy'] }); + }); + + it('leaves it to the tools mapping it once only their servers are left', async () => { await reconcileMcpForConfig(teamConfig, projectConfig); - await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + await setServers({}); await reconcileMcpForConfig(teamConfig, projectConfig); expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); - expect((await ledger())[path.join(projectRoot, '.mcp.json')]).toEqual({ tools: ['claude'] }); + expect(Object.keys(await ledger())).not.toContain(mcpJson()); }); }); @@ -1716,10 +1799,11 @@ servers: mcpServers: { 'with-secret': { type: 'http', url: 'https://mine.example/mcp' } }, })], ])('when this pull listed it and then wrote nothing, as %s', async (_label, arrange) => { + await pulledBefore(); await arrange(); await writeMcpYaml(withSecret); - await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(unmovedConfig(), claudeOnly()); expect(await fse.readFile(mcpJson(), 'utf-8')).not.toContain('super-secret-value'); expect(await excludeOf(projectRoot)).not.toContain('teamai'); @@ -1887,12 +1971,13 @@ servers: }); it('when a server of the member\'s own was in the config before teamai first wrote to it', async () => { + await pulledBefore(); await fse.writeJson(mcpJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); await writeMcpYaml(withSecret); - await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(unmovedConfig(), claudeOnly()); await writeMcpYaml(open); - await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(unmovedConfig(), claudeOnly()); expect(await fse.readJson(mcpJson())).toEqual({ mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' }, open: expect.anything() }, @@ -1901,12 +1986,13 @@ servers: }); it('when `teamai mcp remove` takes teamai\'s servers out of a config that also holds the member\'s own', async () => { + await pulledBefore(); await fse.writeJson(mcpJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); await writeMcpYaml(withSecret); - await reconcileMcpForConfig(teamConfig, claudeOnly()); + await reconcileMcpForConfig(unmovedConfig(), claudeOnly()); - await reconcileMcpForConfig(teamConfig, claudeOnly(), { removeAll: true }); - await releaseCleanMcpGitExcludes(teamConfig, claudeOnly()); + await reconcileMcpForConfig(unmovedConfig(), claudeOnly(), { removeAll: true }); + await releaseCleanMcpGitExcludes(unmovedConfig(), claudeOnly()); expect(await fse.readJson(mcpJson())).toEqual({ mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); expect(await excludeOf(projectRoot)).not.toContain('teamai'); @@ -2194,7 +2280,7 @@ servers: await writeMcpYaml(withSecret); try { - await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(unmovedConfig(), projectConfig); } finally { beforeJsonWrite.run = null; } @@ -2230,6 +2316,7 @@ servers: await fse.writeJson(customFile(), mine); }], ])('so a config of the member\'s own there is not kept listed once the mapping changes, when %s', async (_label, arrange) => { + await pulledBefore(); await writeMcpYaml(withSecret); await arrange(); expect(await fse.readJson(customFile())).toEqual(mine); @@ -2298,33 +2385,114 @@ servers: expect(Object.keys((await readResolvedMcpFiles(projectConfig)).files)).toEqual([mcpJson()]); }, 30_000); - it.each([ - ['it is deleted after a pull rebuilt the lost record', async () => { - await writeMcpYaml(withSecret); - await reconcileMcpForConfig(teamConfig, claudeOnly()); + // Cursor's file: CodeBuddy's built-in location is Claude's .mcp.json, which TOOL_PATHS moves CodeBuddy off. + describe('while this worktree has no managed-mcp.json at all either', () => { + const cursorJson = (): string => path.join(projectRoot, '.cursor', 'mcp.json'); + const lost = async (): Promise => { await loseManifest(); - await writeMcpYaml(open); - vi.stubEnv('SECRET_TOKEN', ''); - await reconcileMcpForConfig(teamConfig, claudeOnly()); await fse.remove(await sidecarFile()); - }], - ['an older teamai, which kept none, wrote the config and rebuilt the lost record', async () => { + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + }; + + beforeEach(async () => { await writeMcpYaml(withSecret); - await reconcileMcpForConfig(teamConfig, claudeOnly()); - await fse.remove(await sidecarFile()); - await loseManifest(); - await writeMcpYaml(open); + await reconcileMcpForConfig(teamConfig, projectConfig); + await lost(); vi.stubEnv('SECRET_TOKEN', ''); - await reconcileMcpForConfig(teamConfig, claudeOnly()); - }], - ])('releases the line of a stale entry whose value is no longer set when %s', async (_label, arrange) => { - await arrange(); + }); - await reconcileMcpForConfig(teamConfig, claudeOnly()); + it.each([ + ['still delivers to it', open], + ['delivers nothing to it', `${open} tools: [claude]\n`], + ])('lists a config holding a stale entry, and keeps it on the pulls after, when the team %s', async (_label, yaml) => { + await writeMcpYaml(yaml); - // The documented limit: without the note, the stale entry looks like the member's own. - expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('super-secret-value'); - expect(await excludeOf(projectRoot)).not.toContain('teamai'); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.cursor\/mcp\.json/); + }); + + // The cost: a first pull in a new worktree cannot tell a member's own server from a stale one of teamai's. + it('lists a config holding only a server of the member\'s own at the first pull in a worktree, until it leaves', async () => { + await fse.writeJson(cursorJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + const doc = await fse.readJson(cursorJson()) as { mcpServers: Record }; + delete doc.mcpServers.mine; + await fse.writeJson(cursorJson(), doc); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/\.cursor\/mcp\.json/); + }); + + it('lets the line go once the member takes the stale entry out', async () => { + await writeMcpYaml(open); + await reconcileMcpForConfig(teamConfig, projectConfig); + const doc = await fse.readJson(cursorJson()) as { mcpServers: Record }; + delete doc.mcpServers['with-secret']; + await fse.writeJson(cursorJson(), doc); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/\.cursor\/mcp\.json/); + }); + + it('leaves one git tracks as it is, and says nothing', async () => { + git(projectRoot, 'add', '.cursor/mcp.json'); + vi.mocked(log.warn).mockClear(); + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/\.cursor\/mcp\.json/); + expect(vi.mocked(log.warn).mock.calls.flat().join('\n')).not.toContain(cursorJson()); + }); + }); + + // Cursor's file: CodeBuddy's built-in location is Claude's .mcp.json, which TOOL_PATHS moves CodeBuddy off. + describe('releases the line of a stale entry whose value is no longer set', () => { + const cursorJson = (): string => path.join(projectRoot, '.cursor', 'mcp.json'); + const cursorOnly = (): LocalConfig => ({ ...projectConfig, disabledAgents: ['claude', 'tclaude'] } as LocalConfig); + + it.each([ + ['it is deleted after a pull rebuilt the lost record', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, cursorOnly()); + await loseManifest(); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + await reconcileMcpForConfig(teamConfig, cursorOnly()); + await fse.remove(await sidecarFile()); + }], + ['an older teamai, which kept none, wrote the config and rebuilt the lost record', async () => { + await writeMcpYaml(`${withSecret}${open.replace('servers:\n', '')}`); + await reconcileMcpForConfig(teamConfig, cursorOnly()); + await fse.remove(await sidecarFile()); + // Its rebuild records what it wrote, open, and notes nothing else. + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record>; + await fse.writeJson(manifestFile, { 'cursor:project': manifest['cursor:project'].filter((record) => record.name === 'open') }); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + }], + ])('when %s', async (_label, arrange) => { + await arrange(); + + await reconcileMcpForConfig(teamConfig, cursorOnly()); + + // The documented limit: without the note, the stale entry looks like the member's own. + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).not.toContain('teamai'); + }); }); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 90d0fbb39..589f5a639 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -975,8 +975,22 @@ describe('uninstall', () => { ].join('\n')); }); + // CodeBuddy stays at its built-in .mcp.json: moved or dropped, any server there Claude's records don't own holds it. + const withCodeBuddy = (localConfig: LocalConfig): void => { + mockAutoDetectInit.mockResolvedValue({ + localConfig, + teamConfig: makeTeamConfig({ + toolPaths: { + claude: { skills: '.claude/skills', mcp: '.claude.json', mcpProject: '.mcp.json' }, + codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' }, + }, + }), + }); + }; + it('names the variable whose value keeps the block', async () => { - const { projectRoot, excludeFile } = await setup(); + const { projectRoot, excludeFile, localConfig } = await setup(); + withCodeBuddy(localConfig); vi.stubEnv('TEAM_BASE_URL', 'https://base.example'); await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { mine: { url: 'https://base.example/mcp' } } }); const { log } = await import('../utils/logger.js'); @@ -1063,8 +1077,10 @@ describe('uninstall', () => { vi.stubEnv('USER', 'longusername1'); const mine = { command: path.join(homeDir, 'bin', 'mine'), env: { OWNER: 'longusername1' } }; await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira, mine } }); + withCodeBuddy(localConfig); await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { [managedMcpManifestKey('claude', true)]: [{ name: 'jira', hash: 'h' }], + [managedMcpManifestKey('codebuddy', true)]: [], }); await uninstall({ force: true }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 9f724386c..ebce1937d 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -560,7 +560,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise 0) + || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, desired)) await hold(target.file); } // And a file a pull wrote under a mapping the team has since changed, but one recorded as tracked while git // tracks it: no line protects it. In a file another tool now maps, that tool's records tell its own servers. diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 94557495a..10f676759 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -290,16 +290,18 @@ export async function resolveMcpTargets( } /** - * The built-in fallbacks among `targets` no current mapping of any tool - * reaches (#882): the team moved or dropped their tool, so its manifest + * The built-in fallbacks among `targets` their own tool's current mapping does + * not reach (#882): the team moved or dropped the tool, so its manifest * records describe another file, or none, while an earlier pull may have - * written this one. One another tool maps today is left to that tool's rules. + * written this one. In one another tool maps today (CodeBuddy's `.mcp.json`, + * which Claude maps), that tool's records tell its own servers. */ export async function unmappedMcpDefaults(targets: McpTarget[]): Promise> { - const mappedNow = await Promise.all(targets.filter((t) => !t.builtinFallback).map((t) => realFilePath(t.file))); const unmapped = new Set(); for (const target of targets) { - if (target.builtinFallback && !mappedNow.includes(await realFilePath(target.file))) unmapped.add(target); + if (!target.builtinFallback) continue; + const own = await Promise.all(targets.filter((t) => t.tool === target.tool && !t.builtinFallback).map((t) => realFilePath(t.file))); + if (!own.includes(await realFilePath(target.file))) unmapped.add(target); } return unmapped; } @@ -615,6 +617,17 @@ export async function resolvedValueEvidence( return variable ? `the value of $${variable}` : null; } +/** + * The servers in `target`'s file that none of `claimed` names, in a file git + * does not track (#882): judged while the worktree has no managed-mcp.json + * (`claimed`: the records a pull wrote there since, if any), when any of them + * may be one teamai wrote. None for a file git tracks: no line protects it. + */ +export async function unclaimedMcpServers(target: McpTarget, claimed: readonly string[]): Promise { + const unclaimed = [...(await installedMcpEntries(target))?.keys() ?? []].filter((name) => !claimed.includes(name)); + return unclaimed.length === 0 || (await gitTracks(target.file)).kind === 'tracked' ? [] : unclaimed; +} + /** `load`, run once, on the first call. */ function once(load: () => Promise): () => Promise { let value: Promise | undefined; @@ -1079,8 +1092,19 @@ async function protectProjectMcpConfigs( // Tried before its write this run, and reported there. const targets = mapped.filter((target) => !unmapped.has(target) && exclusions.get(target.file)?.kind !== 'failed'); const { files: ledger, earlierMappingsRead } = await readResolvedMcpFiles(localConfig); - const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => - await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null; + // No managed-mcp.json when this pull began: a server no record it wrote claims may be one teamai wrote. + // Noted after the settle, as a rebuild of a lost record notes the servers it did not write. + const lost = Object.keys(before ?? manifest).length === 0; + const unclaimed = new Map(); + const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => { + // One file two tools map: what either's record claims. + const claimed = targets.filter((t) => t.file === target.file) + .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); + const names = lost ? await unclaimedMcpServers(target, claimed) : []; + if (names.length > 0) unclaimed.set(target.file, names); + return names.length > 0 + || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null; + }; const observations = await observeMcpConfigs(localConfig, targets, manifest, holds); // Once per worktree, what a teamai that kept no record of paths wrote under a mapping the team has since changed. const earlier = earlierMappingsRead ? [] : await earlierMappedMcpTargets(localConfig, mapped).catch((e: unknown) => { @@ -1118,6 +1142,25 @@ async function protectProjectMcpConfigs( await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before, found); // After the release, which reads the files recorded before this run; also lists one an older teamai wrote. await settleRecordedMcpConfigs(localConfig, observations, { earlierMappingsRead: !earlierMappingsRead && earlier !== null }); + if (unclaimed.size > 0) await noteUnclaimedMcpServers(localConfig, unclaimed); +} + +/** + * Note the servers no record claimed in each config a pull that found no + * managed-mcp.json listed for them (#882): once its manifest is back, a stale + * entry teamai wrote looks like the member's own. After the settle, which + * records the file. + */ +async function noteUnclaimedMcpServers(localConfig: LocalConfig, unclaimed: Map): Promise { + const found = [...unclaimed].map(([file, names]) => ({ file, names })); + const result = await recordUnverifiedMcpServers(localConfig, found).catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result !== 'written' && result !== 'unchanged') { + log.debug( + `Did not note the MCP servers teamai found in ${found.map((f) => f.file).join(', ')} with no managed-mcp.json: ` + + `${result === 'locked' ? 'another teamai command held managed-mcp-files.json past the wait' : result}. ` + + 'Once this pull\'s record is back, a stale entry among them looks like the member\'s own.', + ); + } } /** From 978ecec5fd3ad7028b00d28b70089bf1e97492cb Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 15:30:11 +0200 Subject: [PATCH 54/85] docs(mcp): describe the no-manifest rule, a moved tool's built-in location another tool maps, and a re-including ignore rule (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 22 ++++++++++++--------- skill-data/setup/references/uninstall.md | 5 +++-- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b73a97ffd..07369774e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1162,7 +1162,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none, unless another tool maps that path today: then that tool's rules judge it), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, or found in that history), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 2985f94c7..6bd131fea 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1065,7 +1065,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;但若当前有另一个工具映射到该路径,则按那个工具的规则判断),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件,或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录,或在上述历史中找到)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 4eae4b493..2e53b7817 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -44,11 +44,12 @@ that write, teamai lists the file in the clone's `.git/info/exclude`, inside a A file under a symlinked directory is listed and checked where the write lands (`.cursor/` linking to `config/`: `/config/mcp.json`); a symlink at the file itself is replaced by the write. -When it cannot (git already tracks the file, `.git/info` is not writable, the -exclude file is held by another teamai command, or git errors), it leaves the file -as it was, warns, and `teamai mcp list` shows `withheld: — . `. -Apply the fix it names (a tracked file: `git rm --cached ` and rotate the -token), then run `teamai pull`. A pull or `teamai mcp remove` takes a line out +When it cannot (git already tracks the file, a rule in the member's git ignore +files re-includes it, `.git/info` is not writable, the exclude file is held by +another teamai command, or git errors), it leaves the file as it was, warns, and +`teamai mcp list` shows `withheld: — . `. Apply the fix it +names (a tracked file: `git rm --cached ` and rotate the token; a +re-including rule such as `!/.mcp.json`: remove it), then run `teamai pull`. A pull or `teamai mcp remove` takes a line out once its file no longer holds a resolved value; `teamai uninstall` does so in every worktree. A file written under a `toolPaths..mcpProject` the team later changes or removes stays listed until it is deleted or holds no server; @@ -60,10 +61,13 @@ on it. `teamai doctor` checks those paths until that pull. A file written for a tool the team moved elsewhere (recorded, or found in that history), that another tool still maps, stays listed while it holds a server that tool did not write, one of the member's own included. The built-in location of a tool the team drops -from `toolPaths` or moves elsewhere stays listed while it holds any server, -unless another tool maps that path today (CodeBuddy's `.mcp.json`, which Claude -maps), which then judges it. A file two tools map, with no pull on this version -having recorded it, needs a `managed-mcp.json` record from each of them. +from `toolPaths` or moves elsewhere stays listed while it holds any server; one +another tool maps today (CodeBuddy's `.mcp.json`, which Claude maps) while it +holds a server that tool did not write. A file two tools map, with no pull on +this version having recorded it, needs a `managed-mcp.json` record from each of +them. While a worktree has no `managed-mcp.json` at all (lost, or before its +first pull), an untracked config holding a server no record claims is listed, +and that server noted: it keeps the line until it leaves the file. ## Invite a member diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 4a680d8f0..069f637bb 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -62,8 +62,9 @@ and give it your team repo URL."* `.cursor/` linking to `config/`). For one it cannot prove clean (including one written under a `toolPaths` mapping since changed, at the built-in location of a tool the team dropped or moved that no other tool maps, or in a nested repository's - linked worktree, that still holds servers, and one written for a tool since moved that - another tool maps, holding a server that tool did not write) it keeps the line + linked worktree, that still holds servers, and one written for a tool since moved + (or at its built-in location) that another tool maps, holding a server that tool + did not write) it keeps the line and warns, naming the file and why: have the user remove teamai's servers from that file, then delete the line (with the last one, the block's markers). Do not delete a kept line while its file still holds a token. From 0f6db9e11e87b3c5751b73af521200c177d949ee Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 15:53:49 +0200 Subject: [PATCH 55/85] fix(mcp): keep a tool's record as it was when its config does not parse, and take back each tool a write that did not happen recorded (#882) --- src/__tests__/mcp-reconcile.test.ts | 34 +++++++++++++++++++++++++++++ src/mcp-reconcile.ts | 14 +++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 8af81870e..0e5238353 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -2293,6 +2293,40 @@ servers: expect((await fse.stat(resolvedMcpFilesPath(projectConfig) ?? '')).mode & 0o777).toBe(0o600); }); + // An empty record says teamai owns nothing left in the file, which a pull that could not read it cannot say. + it('keeps a tool\'s record as it was when this pull could not read its config, so a stale entry keeps its line once repaired', async () => { + const cursorJson = path.join(projectRoot, '.cursor', 'mcp.json'); + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, projectConfig); + const repaired = await fse.readFile(cursorJson, 'utf-8'); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + await fse.writeFile(cursorJson, '{ "mcpServers": '); + vi.stubEnv('SECRET_TOKEN', ''); + await reconcileMcpForConfig(teamConfig, projectConfig); + + await fse.writeFile(cursorJson, repaired); + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await fse.readFile(cursorJson, 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.cursor\/mcp\.json/); + }); + + it('takes back a tool it recorded before a write that did not happen, in a file another tool recorded', async () => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + const mcpJson = path.join(projectRoot, '.mcp.json'); + await writeMcpYaml(`${withSecret} tools: [claude]\n`); + await reconcileMcpForConfig(unmovedConfig(), projectConfig); + expect((await readResolvedMcpFiles(projectConfig)).files[mcpJson]).toEqual({ tools: ['claude'] }); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(withSecret); + await fse.writeFile(mcpJson, '{ "mcpServers": '); + + await reconcileMcpForConfig(unmovedConfig(), projectConfig); + + expect((await readResolvedMcpFiles(projectConfig)).files[mcpJson]).toEqual({ tools: ['claude'] }); + }); + describe('forgets a config it recorded before a write that did not happen', () => { const custom = { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.cursor/team-mcp.json' } }; const customFile = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 10f676759..2916c4875 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1032,7 +1032,7 @@ export async function reconcileMcpForConfig( const exclusions = new Map(); // The project configs this run wrote: a line it added for one stays, whatever fails after. const written = new Set(); - // The project configs managed-mcp-files.json first recorded this run, before their write. + // The (file, tool) pairs managed-mcp-files.json first recorded this run, before their write. const recorded: McpTarget[] = []; const protect = !options.removeAll && !options.dryRun; // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. @@ -1292,7 +1292,8 @@ async function reconcileTargets( const nothingOwned = Object.values(manifest).every((r) => r.length === 0); if (teamDefs.length === 0 && nothingOwned) return { changes, wrote }; // The files an earlier pull recorded, and each record this run rebuilds after it was lost (#882). - const listed = localConfig.scope === 'project' && !options.dryRun ? new Set(Object.keys((await readResolvedMcpFiles(localConfig)).files)) : new Set(); + const ledger = localConfig.scope === 'project' && !options.dryRun ? (await readResolvedMcpFiles(localConfig)).files : {}; + const listed = new Set(Object.keys(ledger)); const rebuilt: Array<{ target: McpTarget; records: ManagedMcpRecord[] }> = []; const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); @@ -1328,7 +1329,7 @@ async function reconcileTargets( // Recorded before the write, so a later change to toolPaths still finds the file. if (!options.dryRun) { await recordResolvedMcpFile(localConfig, target); - if (!listed.has(target.file)) recorded.push(target); + if (!ledger[target.file]?.tools.includes(target.tool)) recorded.push(target); } } @@ -1337,6 +1338,8 @@ async function reconcileTargets( : await applyJson(target, desired, owned, ownedNames, nextRecords, changes, options); if (wroteTarget) written.add(target.file); wrote = wroteTarget || wrote; + // Not read: its record stays as it was, or absent. An empty one would say teamai owns nothing there (#882). + if (wroteTarget === null) continue; // Whether each entry holds a resolved value: once its definition stops // needing one, what this pull wrote still does (#882). @@ -1420,6 +1423,7 @@ async function forgetUnwrittenMcpConfigs(localConfig: LocalConfig, targets: McpT // ─── Appliers ──────────────────────────────────────────────── +/** Whether it wrote `target`'s file; null when the file does not parse, and so was not read. */ async function applyJson( target: McpTarget, desired: Map, @@ -1428,13 +1432,13 @@ async function applyJson( nextRecords: ManagedMcpRecord[], changes: McpChange[], options: McpReconcileOptions, -): Promise { +): Promise { const serverKey = MCP_SERVER_KEY[target.format as Exclude]; const allowBare = target.format === 'copilot' && target.projectScope; const doc = await readJsonDoc(target.file, serverKey, allowBare); if (!doc) { log.warn(`Could not parse ${target.file} — skipping MCP injection for ${target.tool}`); - return false; + return null; } const ownedHash = new Map(owned.map((r) => [r.name, r.hash])); From 6b177e808bcaad2d39f56ea10de97f463e4ee556 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:01:36 +0200 Subject: [PATCH 56/85] fix(mcp): mark the records a pull with no managed-mcp.json writes as unnoted until the servers no record claims are noted (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 4 +- src/__tests__/mcp-reconcile.test.ts | 68 +++++++++++++++++ src/mcp-reconcile.ts | 84 +++++++++++++++++---- src/types.ts | 7 +- 6 files changed, 146 insertions(+), 21 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 97e19fc18..92c7bf4ba 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1162,7 +1162,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one a pull rebuilt while it could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no `managed-mcp.json` at all, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 4978c19bd..1ac91075c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1065,7 +1065,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在完全没有 `managed-mcp.json` 时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 2e53b7817..2104fc1b5 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -67,7 +67,9 @@ holds a server that tool did not write. A file two tools map, with no pull on this version having recorded it, needs a `managed-mcp.json` record from each of them. While a worktree has no `managed-mcp.json` at all (lost, or before its first pull), an untracked config holding a server no record claims is listed, -and that server noted: it keeps the line until it leaves the file. +and that server noted: it keeps the line until it leaves the file. While that +note cannot be written (another teamai command holds the record), the line stays +until a later pull writes it. ## Invite a member diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 0e5238353..1353dc708 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -2451,6 +2451,74 @@ servers: expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.cursor\/mcp\.json/); }); + it('keeps the line of a config holding a stale entry on the pulls after one that could not note it, and notes it once it can', async () => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + await writeMcpYaml(open); + const lock = `${await sidecarFile()}.teamai-lock`; + await fse.ensureDir(path.dirname(lock)); + expect(await acquireLock(lock)).toBe(true); + try { + await reconcileMcpForConfig(teamConfig, projectConfig); + } finally { + await releaseLock(lock); + } + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect((await readResolvedMcpFiles(projectConfig)).files[cursorJson()]?.unverified).toEqual(['with-secret']); + const doc = await fse.readJson(cursorJson()) as { mcpServers: Record }; + delete doc.mcpServers['with-secret']; + await fse.writeJson(cursorJson(), doc); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await excludeOf(projectRoot)).not.toMatch(/\.cursor\/mcp\.json/); + }, 30_000); + + it('keeps that line through a pull that rewrites the record while the note still cannot land', async () => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + await writeMcpYaml(open); + const lock = `${await sidecarFile()}.teamai-lock`; + await fse.ensureDir(path.dirname(lock)); + expect(await acquireLock(lock)).toBe(true); + try { + await reconcileMcpForConfig(teamConfig, projectConfig); + await writeMcpYaml(`${open} - name: more\n transport: http\n url: https://example.com/more\n`); + await reconcileMcpForConfig(teamConfig, projectConfig); + } finally { + await releaseLock(lock); + } + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect((await readResolvedMcpFiles(projectConfig)).files[cursorJson()]?.unverified).toEqual(['with-secret']); + }, 30_000); + + it('keeps that line through a pull that empties the record while the note still cannot land', async () => { + await writeMcpYaml(open); + const lock = `${await sidecarFile()}.teamai-lock`; + await fse.ensureDir(path.dirname(lock)); + expect(await acquireLock(lock)).toBe(true); + try { + await reconcileMcpForConfig(teamConfig, projectConfig); + await writeMcpYaml(`${open} tools: [claude]\n`); + await reconcileMcpForConfig(teamConfig, projectConfig); + } finally { + await releaseLock(lock); + } + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }, 30_000); + // The cost: a first pull in a new worktree cannot tell a member's own server from a stale one of teamai's. it('lists a config holding only a server of the member\'s own at the first pull in a worktree, until it leaves', async () => { await fse.writeJson(cursorJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 2916c4875..d3aac922d 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1084,7 +1084,7 @@ async function protectProjectMcpConfigs( ): Promise { const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); const teamDefs = resolution.kind === 'failed' ? null : resolution.entries.map((entry) => teamMcpToDef(entry.entry)); - const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const { manifestPath, manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const vars = await buildVarTable(localConfig); const ctx = once(() => buildDesiredMcpContext(teamConfig, localConfig)); const mapped = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); @@ -1092,15 +1092,18 @@ async function protectProjectMcpConfigs( // Tried before its write this run, and reported there. const targets = mapped.filter((target) => !unmapped.has(target) && exclusions.get(target.file)?.kind !== 'failed'); const { files: ledger, earlierMappingsRead } = await readResolvedMcpFiles(localConfig); - // No managed-mcp.json when this pull began: a server no record it wrote claims may be one teamai wrote. - // Noted after the settle, as a rebuild of a lost record notes the servers it did not write. + // No managed-mcp.json when this pull began, or a record of a tool mapping the file still marked unnoted: + // a server no record claims may be one teamai wrote. Noted after the settle, as a rebuild of a lost record + // notes the servers it did not write. const lost = Object.keys(before ?? manifest).length === 0; + const unnoted = (file: string): boolean => lost || targets.some((t) => t.file === file + && [before, manifest].some((m) => m?.[managedMcpManifestKey(t.tool, true)]?.some((record) => record.unnoted))); const unclaimed = new Map(); const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => { // One file two tools map: what either's record claims. const claimed = targets.filter((t) => t.file === target.file) .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); - const names = lost ? await unclaimedMcpServers(target, claimed) : []; + const names = unnoted(target.file) ? await unclaimedMcpServers(target, claimed) : []; if (names.length > 0) unclaimed.set(target.file, names); return names.length > 0 || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null; @@ -1142,25 +1145,56 @@ async function protectProjectMcpConfigs( await releaseMcpGitExcludes(teamConfig, localConfig, projectRoot, addedNow, before, found); // After the release, which reads the files recorded before this run; also lists one an older teamai wrote. await settleRecordedMcpConfigs(localConfig, observations, { earlierMappingsRead: !earlierMappingsRead && earlier !== null }); - if (unclaimed.size > 0) await noteUnclaimedMcpServers(localConfig, unclaimed); + const noted = await noteUnclaimedMcpServers(localConfig, unclaimed); + // A file that parses with no server left unclaimed has nothing to note. + const parses = (target: McpTarget): boolean => + observations.some((o) => o.file === target.file && o.tool === target.tool && o.state.kind !== 'unparsable'); + await markMcpRecordsNoted(manifestPath, manifest, targets.filter((target) => unnoted(target.file) + && (unclaimed.has(target.file) ? noted.has(target.file) : parses(target)))); } /** * Note the servers no record claimed in each config a pull that found no * managed-mcp.json listed for them (#882): once its manifest is back, a stale * entry teamai wrote looks like the member's own. After the settle, which - * records the file. + * records the file. Returns the files whose servers are noted now. */ -async function noteUnclaimedMcpServers(localConfig: LocalConfig, unclaimed: Map): Promise { +async function noteUnclaimedMcpServers(localConfig: LocalConfig, unclaimed: Map): Promise> { + if (unclaimed.size === 0) return new Set(); const found = [...unclaimed].map(([file, names]) => ({ file, names })); const result = await recordUnverifiedMcpServers(localConfig, found).catch((e: unknown) => e instanceof Error ? e.message : String(e)); - if (result !== 'written' && result !== 'unchanged') { + // Read back: a file the settle did not record takes no note. + const { files } = await readResolvedMcpFiles(localConfig); + const noted = new Set(found.filter(({ file, names }) => names.every((name) => files[file]?.unverified?.includes(name))).map((f) => f.file)); + const missed = found.filter((f) => !noted.has(f.file)).map((f) => f.file); + if (missed.length > 0) { + const why = result === 'locked' ? 'another teamai command held managed-mcp-files.json past the wait' + : result === 'written' || result === 'unchanged' ? 'managed-mcp-files.json has no record of the file' : result; log.debug( - `Did not note the MCP servers teamai found in ${found.map((f) => f.file).join(', ')} with no managed-mcp.json: ` - + `${result === 'locked' ? 'another teamai command held managed-mcp-files.json past the wait' : result}. ` - + 'Once this pull\'s record is back, a stale entry among them looks like the member\'s own.', + `Did not note the MCP servers teamai found in ${missed.join(', ')} that no managed-mcp.json record claims: ${why}. ` + + 'They keep their .git/info/exclude lines while they hold MCP servers; the next pull tries again.', ); } + return noted; +} + +/** + * Take the unnoted mark off the records of `targets`' tools, whose files' + * other servers are noted (#882). A failed write keeps it: the file keeps its + * line while it holds a server, and the next pull notes them again. + */ +async function markMcpRecordsNoted(manifestPath: string, manifest: ManagedMcpManifest, targets: McpTarget[]): Promise { + let changed = false; + for (const { tool } of targets) { + for (const record of manifest[managedMcpManifestKey(tool, true)] ?? []) { + changed ||= record.unnoted === true; + delete record.unnoted; + } + } + if (!changed) return; + await writeJsonAtomic(manifestPath, manifest).catch((e: unknown) => { + log.debug(`Did not update ${manifestPath}: ${e instanceof Error ? e.message : String(e)}. The next pull notes its MCP configs' other servers again.`); + }); } /** @@ -1295,6 +1329,8 @@ async function reconcileTargets( const ledger = localConfig.scope === 'project' && !options.dryRun ? (await readResolvedMcpFiles(localConfig)).files : {}; const listed = new Set(Object.keys(ledger)); const rebuilt: Array<{ target: McpTarget; records: ManagedMcpRecord[] }> = []; + // No managed-mcp.json when this pull began (#882): its records are marked below. + const lost = localConfig.scope === 'project' && !options.dryRun && Object.keys(manifest).length === 0; const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); @@ -1341,12 +1377,15 @@ async function reconcileTargets( // Not read: its record stays as it was, or absent. An empty one would say teamai owns nothing there (#882). if (wroteTarget === null) continue; + // The unnoted mark stays until a note of what else is in the file lands. + const marked = manifest[manifestKey]?.some((record) => record.unnoted) ?? false; // Whether each entry holds a resolved value: once its definition stops // needing one, what this pull wrote still does (#882). if (target.projectScope) { for (const record of nextRecords) { record.resolved ??= carriesResolvedValue(target, teamDefs, [record.name]); - delete record.unnoted; + if (marked) record.unnoted = true; + else delete record.unnoted; } } // Rebuilt this run, or by one that could not note what else was in the file. @@ -1354,14 +1393,29 @@ async function reconcileTargets( if (listed.has(target.file) && unnoted && nextRecords.length > 0) rebuilt.push({ target, records: nextRecords }); // An emptied project record stays: it says teamai owns nothing left in that // file, which a lost record cannot, and so lets its exclude line go (#882). - if (nextRecords.length > 0 || (target.projectScope && manifest[manifestKey] !== undefined)) manifest[manifestKey] = nextRecords; + // Not while the file's other servers are unnoted: it would say the same. + if (nextRecords.length > 0 || (target.projectScope && manifest[manifestKey] !== undefined && !marked)) manifest[manifestKey] = nextRecords; else delete manifest[manifestKey]; } + // With no managed-mcp.json when this pull began, a record of a file holding a server no record claims is + // unnoted until protectProjectMcpConfigs notes that server, after its settle records the file. + for (const target of lost ? targets : []) { + const records = manifest[managedMcpManifestKey(target.tool, true)] ?? []; + const claimed = targets.filter((t) => t.file === target.file) + .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); + if (records.length > 0 && (await unclaimedMcpServers(target, claimed)).length > 0) { + for (const record of records) record.unnoted = true; + } + } if (!options.dryRun && (wrote || rebuilt.length > 0)) { // Before the manifest: once it is written, only a record marked unnoted says it was rebuilt. - for (const records of await noteUnverifiedMcpServers(localConfig, rebuilt)) { - for (const record of records) record.unnoted = true; + const failed = await noteUnverifiedMcpServers(localConfig, rebuilt); + for (const { records } of rebuilt) { + for (const record of records) { + if (failed.includes(records)) record.unnoted = true; + else delete record.unnoted; + } } await writeJsonAtomic(manifestPath, manifest); } diff --git a/src/types.ts b/src/types.ts index a0219da8b..da5380877 100644 --- a/src/types.ts +++ b/src/types.ts @@ -904,9 +904,10 @@ export interface ManagedMcpRecord { */ resolved?: boolean; /** - * Project scope: this record was rebuilt after it was lost, and the other - * servers in its file could not be noted in managed-mcp-files.json (#882). - * Until a pull notes them, the file counts as having no record. + * Project scope: this record was rebuilt after it was lost, or written by a + * pull that found no managed-mcp.json, and the other servers in its file + * could not be noted in managed-mcp-files.json yet (#882). Until a pull + * notes them, the file counts as having no record. */ unnoted?: true; } From 95ee7689a34bdae4d4751219d2c4709042e5b365 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:04:32 +0200 Subject: [PATCH 57/85] fix(mcp): have doctor judge a record marked unnoted like a missing managed-mcp.json (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 13 +++++++++++++ src/doctor-delivery.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index f713a5eda..2f49bf432 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -617,6 +617,19 @@ describe('doctor — MCP servers delivered on disk', () => { expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); }); + it('fails the same way while the record a pull wrote without managed-mcp.json is still marked unnoted', async () => { + teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'fixture-hash', unnoted: true }], + }); + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); + }); + it('emits no check when the server of that name is the member\'s own, not teamai\'s', async () => { // CodeBuddy at its built-in .mcp.json: dropped, any server there Claude's records don't own would hold it. teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index ebce1937d..94955a9a1 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -596,8 +596,10 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise 0) + // No managed-mcp.json at all, or a record a pull wrote without one whose note hasn't landed: any + // server no record claims may be teamai's, as pull judges it. + if (((Object.keys(manifest).length === 0 || owned.some((record) => record.unnoted)) + && (await unclaimedMcpServers(target, owned.map((record) => record.name))).length > 0) || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, desired)) await hold(target.file); } // And a file a pull wrote under a mapping the team has since changed, but one recorded as tracked while git From a915ed1a8a18bc10c56676b6210063c340eea288 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:13:31 +0200 Subject: [PATCH 58/85] fix(mcp): judge a config tools of different formats share in each of their formats before releasing its line (#882) --- src/__tests__/mcp-reconcile.test.ts | 20 ++++++++++++++ src/mcp-reconcile.ts | 43 +++++++++++++++++++---------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 1353dc708..fd90d0635 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -921,6 +921,26 @@ servers: await fse.outputJson(managedMcpManifestPath(getDataHome(projectConfig), projectRoot), { 'claude:project': [], 'cursor:project': [] }); }; + it('keeps the line of a file tools of different formats share while a stale entry sits under any of their keys', async () => { + // Cursor (mcpServers) and OpenCode (mcp) both on .mcp.json, OpenCode last: judged in one format, the other hides. + const toolPaths = { + ...UNMOVED_TOOL_PATHS, + cursor: { ...TOOL_PATHS.cursor, mcpProject: '.mcp.json' }, + opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: '.mcp.json' }, + }; + const shared = { ...teamConfig, toolPaths } as TeamaiConfig; + await writeMcpYaml(`${withSecret} tools: [cursor]\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n`); + await reconcileMcpForConfig(shared, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + describe('a config two tools share (Claude and CodeBuddy on .mcp.json)', () => { const shared = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } } } as TeamaiConfig; const open = ' - name: open\n transport: http\n url: https://example.com/open\n'; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index d3aac922d..f764223e0 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -894,6 +894,8 @@ export async function mcpConfigsNotProvenClean( const targets = new Map; mapsToday: Set; proven: Set; writers: Set; + /** Every tool's target on this file: tools of different formats read different keys of it. */ + all: McpTarget[]; }>(); const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); @@ -943,6 +945,7 @@ export async function mcpConfigsNotProvenClean( mapsToday, proven, writers, + all: [...seen?.all ?? [], target], foreign: foreign || seen?.foreign === true, }); } @@ -987,23 +990,33 @@ export async function mcpConfigsNotProvenClean( held.set(file, movedWhy); continue; } - const known = targets.get(file) + const mappedHere = targets.get(file); + const knownHere = mappedHere ?? (sibling && nested ? { target: { ...sibling.target, file }, owned: [], unverified: [], recorded: false, foreign: true, nested } : undefined); - const installed = known ? await installedMcpEntries(known.target) : null; const raw = (await readFileSafe(file)) ?? ''; - const named = known && installed && teamDefs - ? [...installed.keys()].find((name) => carriesResolvedValue(known.target, teamDefs, [name])) - : undefined; - const why = !known ? 'no tool teamai knows reads it' - : !installed ? 'it does not parse' - : installed.size === 0 ? undefined - : 'nested' in known ? `it holds MCP servers in a linked worktree of the repository at ${known.nested}, which teamai cannot judge` - : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' - : !teamDefs ? 'the team\'s MCP servers cannot be read' - : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` - : await resolvedValueEvidence(known.target, teamDefs, known, vars, ctx).then((e) => e && `it holds ${e}`) - ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] - ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse, has no entry for it or was rebuilt without noting its other servers'); + // Judged in the format of every tool that maps it: one tool's key may hold what another's doesn't. + const judge = async (known: NonNullable, target: McpTarget): Promise => { + const installed = await installedMcpEntries(target); + const named = installed && teamDefs + ? [...installed.keys()].find((name) => carriesResolvedValue(target, teamDefs, [name])) + : undefined; + return !installed ? 'it does not parse' + : installed.size === 0 ? undefined + : 'nested' in known ? `it holds MCP servers in a linked worktree of the repository at ${known.nested}, which teamai cannot judge` + : known.foreign && otherWorktrees === 'empty' ? 'it holds MCP servers in another worktree, which only a pull there can judge' + : !teamDefs ? 'the team\'s MCP servers cannot be read' + : named ? `it holds the team's ${named}, which needs a resolved \${VAR}` + : await resolvedValueEvidence(target, teamDefs, known, vars, ctx).then((e) => e && `it holds ${e}`) + ?? values.filter(([, value]) => raw.includes(value)).map(([name]) => `it holds the value of $${name}`)[0] + ?? (known.recorded ? undefined : 'it holds MCP servers, and managed-mcp.json, teamai\'s record of which it wrote there, is gone, does not parse, has no entry for it or was rebuilt without noting its other servers'); + }; + let why = knownHere ? undefined : 'no tool teamai knows reads it'; + const formats = mappedHere ? mappedHere.all.filter((t, i, all) => all.findIndex((o) => o.format === t.format) === i) + : knownHere ? [knownHere.target] : []; + for (const target of formats) { + why = knownHere && await judge(knownHere, target); + if (why) break; + } if (why) held.set(file, why); } } From c544a38ac7ca3b25a278a333de69b779a81a9fb1 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:23:29 +0200 Subject: [PATCH 59/85] fix(mcp): note the servers no record claims in the file of a tool whose record a pull writes first, as with no managed-mcp.json at all (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 4 +- src/__tests__/mcp-reconcile.test.ts | 48 +++++++++++++++++++++ src/mcp-reconcile.ts | 9 ++-- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 92c7bf4ba..9713fc273 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1162,7 +1162,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no `managed-mcp.json` at all, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. 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 1ac91075c..09782b655 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1065,7 +1065,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在完全没有 `managed-mcp.json` 时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 2104fc1b5..6b3c0a774 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -67,7 +67,9 @@ holds a server that tool did not write. A file two tools map, with no pull on this version having recorded it, needs a `managed-mcp.json` record from each of them. While a worktree has no `managed-mcp.json` at all (lost, or before its first pull), an untracked config holding a server no record claims is listed, -and that server noted: it keeps the line until it leaves the file. While that +and that server noted: it keeps the line until it leaves the file. So is the +file of a tool `managed-mcp.json` has no record for, when a pull writes that +tool's first record (its record lost, or teamai's first delivery to it). While that note cannot be written (another teamai command holds the record), the line stays until a later pull writes it. diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index fd90d0635..ac7a9d45a 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -2579,6 +2579,54 @@ servers: }); }); + // Cursor's file, as above; Claude's record keeps managed-mcp.json from being empty. + describe('while one tool has no record in managed-mcp.json', () => { + const cursorJson = (): string => path.join(projectRoot, '.cursor', 'mcp.json'); + const manifestFile = async (): Promise => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + return managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + }; + + it('lists a config holding a stale entry, and keeps it on the pulls after the one that rebuilds that tool\'s record', async () => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, projectConfig); + const manifest = await fse.readJson(await manifestFile()) as Record; + delete manifest['cursor:project']; + await fse.writeJson(await manifestFile(), manifest); + const sidecar = await fse.readJson(await sidecarFile()) as { files: Record }; + delete sidecar.files[cursorJson()]; + await fse.writeJson(await sidecarFile(), sidecar); + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await fse.readFile(cursorJson(), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + expect((await readResolvedMcpFiles(projectConfig)).files[cursorJson()]?.unverified).toEqual(['with-secret']); + }); + + // The cost, as with no managed-mcp.json at all: a tool's first delivery cannot tell a member's own server from a stale one. + it('lists a config holding only a server of the member\'s own at that tool\'s first delivery, until it leaves', async () => { + await fse.outputJson(await manifestFile(), { 'claude:project': [] }); + await fse.writeJson(cursorJson(), { mcpServers: { mine: { type: 'http', url: 'https://mine.example/mcp' } } }); + await writeMcpYaml(open); + + await reconcileMcpForConfig(teamConfig, projectConfig); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + const doc = await fse.readJson(cursorJson()) as { mcpServers: Record }; + delete doc.mcpServers.mine; + await fse.writeJson(cursorJson(), doc); + await reconcileMcpForConfig(teamConfig, projectConfig); + + expect(await excludeOf(projectRoot)).not.toMatch(/\.cursor\/mcp\.json/); + }); + }); + // Cursor's file: CodeBuddy's built-in location is Claude's .mcp.json, which TOOL_PATHS moves CodeBuddy off. describe('releases the line of a stale entry whose value is no longer set', () => { const cursorJson = (): string => path.join(projectRoot, '.cursor', 'mcp.json'); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index f764223e0..a686214b8 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1342,8 +1342,9 @@ async function reconcileTargets( const ledger = localConfig.scope === 'project' && !options.dryRun ? (await readResolvedMcpFiles(localConfig)).files : {}; const listed = new Set(Object.keys(ledger)); const rebuilt: Array<{ target: McpTarget; records: ManagedMcpRecord[] }> = []; - // No managed-mcp.json when this pull began (#882): its records are marked below. - const lost = localConfig.scope === 'project' && !options.dryRun && Object.keys(manifest).length === 0; + // The tools with no record in managed-mcp.json when this pull began (#882): theirs are marked below. + const unrecorded = new Set(localConfig.scope === 'project' && !options.dryRun + ? targets.filter((t) => manifest[managedMcpManifestKey(t.tool, true)] === undefined).map((t) => t.tool) : []); const desiredContext = await buildDesiredMcpContext(teamConfig, localConfig, options); @@ -1411,9 +1412,9 @@ async function reconcileTargets( else delete manifest[manifestKey]; } - // With no managed-mcp.json when this pull began, a record of a file holding a server no record claims is + // A record of a tool that had none when this pull began, of a file holding a server no record claims, is // unnoted until protectProjectMcpConfigs notes that server, after its settle records the file. - for (const target of lost ? targets : []) { + for (const target of targets.filter((t) => unrecorded.has(t.tool))) { const records = manifest[managedMcpManifestKey(target.tool, true)] ?? []; const claimed = targets.filter((t) => t.file === target.file) .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); From dfe1a743835c399241373fa3519a9dfea375800c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:24:49 +0200 Subject: [PATCH 60/85] fix(mcp): take back a tool managed-mcp-files.json recorded before a write unless its own records hold a resolved value there (#882) --- src/__tests__/mcp-reconcile.test.ts | 28 ++++++++++++++++++++++++++++ src/mcp-reconcile.ts | 9 ++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index ac7a9d45a..27f5b4e48 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -2332,6 +2332,34 @@ servers: expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all')).not.toMatch(/\.cursor\/mcp\.json/); }); + it('takes back a tool it recorded before a write it then skipped, in a file another tool wrote this pull', async () => { + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + const mcpJson = path.join(projectRoot, '.mcp.json'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(withSecret); + + // Claude writes with-secret first; CodeBuddy finds it there, not its own, and skips it. + const result = await reconcileMcpForConfig(unmovedConfig(), projectConfig); + + expect(result.changes).toContainEqual(expect.objectContaining({ tool: 'codebuddy', server: 'with-secret', action: 'skipped' })); + expect((await readResolvedMcpFiles(projectConfig)).files[mcpJson]).toEqual({ tools: ['claude'] }); + }); + + it('keeps a tool it records again whose entry with a resolved value is already in the file, with no write', async () => { + const { readResolvedMcpFiles, resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + const mcpJson = path.join(projectRoot, '.mcp.json'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codebuddy]\n${withSecret.replace('servers:\n', '').replace('with-secret', 'other-secret')} tools: [claude]\n`); + await reconcileMcpForConfig(unmovedConfig(), projectConfig); + expect((await readResolvedMcpFiles(projectConfig)).files[mcpJson]?.tools.sort()).toEqual(['claude', 'codebuddy']); + await fse.writeJson(resolvedMcpFilesPath(projectConfig) ?? '', { version: 1, files: { [mcpJson]: { tools: ['claude'] } } }); + + const result = await reconcileMcpForConfig(unmovedConfig(), projectConfig); + + expect(result.wrote).toBe(false); + expect((await readResolvedMcpFiles(projectConfig)).files[mcpJson]?.tools.sort()).toEqual(['claude', 'codebuddy']); + }); + it('takes back a tool it recorded before a write that did not happen, in a file another tool recorded', async () => { const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); const mcpJson = path.join(projectRoot, '.mcp.json'); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index a686214b8..897ed8f70 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1045,7 +1045,8 @@ export async function reconcileMcpForConfig( const exclusions = new Map(); // The project configs this run wrote: a line it added for one stays, whatever fails after. const written = new Set(); - // The (file, tool) pairs managed-mcp-files.json first recorded this run, before their write. + // The (file, tool) pairs managed-mcp-files.json first recorded this run, before their write, until that + // tool's records hold a resolved value there: another tool's write to the same file proves nothing of it. const recorded: McpTarget[] = []; const protect = !options.removeAll && !options.dryRun; // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. @@ -1053,9 +1054,9 @@ export async function reconcileMcpForConfig( try { return await reconcileTargets(teamConfig, localConfig, options, exclusions, written, recorded); } finally { - // A record this run added for a file it then did not write goes, as its exclude line does. The settle + // A record this run added for a tool that then wrote no value goes, as its exclude line does. The settle // below records the file again if it holds a resolved value all the same (an earlier pull wrote it). - await forgetUnwrittenMcpConfigs(localConfig, recorded.filter((target) => !written.has(target.file))); + await forgetUnwrittenMcpConfigs(localConfig, recorded); // Also after a failed write: what earlier pulls wrote is on disk either way. if (protect) await protectResolvedMcpConfigs(teamConfig, localConfig, exclusions, written, before); } @@ -1401,6 +1402,8 @@ async function reconcileTargets( if (marked) record.unnoted = true; else delete record.unnoted; } + const at = recorded.indexOf(target); + if (at >= 0 && nextRecords.some((record) => record.resolved === true)) recorded.splice(at, 1); } // Rebuilt this run, or by one that could not note what else was in the file. const unnoted = manifest[manifestKey] === undefined || manifest[manifestKey].some((record) => record.unnoted); From 549bcab272c9f0d7ff502143e44e83b74df30525 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:43:09 +0200 Subject: [PATCH 61/85] fix(mcp): treat an installed tool's missing record as lost at every pull and in doctor, not only an empty managed-mcp.json (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 13 +++++++++++++ src/__tests__/mcp-reconcile.test.ts | 19 +++++++++++++++++++ src/doctor-delivery.ts | 11 +++++++---- src/mcp-reconcile.ts | 16 +++++++++++++--- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 2f49bf432..c345ada8a 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -617,6 +617,19 @@ describe('doctor — MCP servers delivered on disk', () => { expect((check.fix ?? '').split(path.join(projectRoot, '.mcp.json'))).toHaveLength(2); }); + it('fails the same way while an installed tool mapping the file has no record, though another tool\'s is there', async () => { + teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'fixture-hash', resolved: false }], + }); + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + }); + it('fails the same way while the record a pull wrote without managed-mcp.json is still marked unnoted', async () => { teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 27f5b4e48..3c1607d90 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -921,6 +921,25 @@ servers: await fse.outputJson(managedMcpManifestPath(getDataHome(projectConfig), projectRoot), { 'claude:project': [], 'cursor:project': [] }); }; + it('lists again the config of a tool whose record alone is lost, while it holds a server no record claims', async () => { + await writeMcpYaml(withSecret); + await reconcileMcpForConfig(teamConfig, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.cursor', 'mcp.json'), 'utf-8')).toContain('super-secret-value'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record; + delete manifest['cursor:project']; + await fse.writeJson(manifestFile, manifest); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(teamConfig, { ...projectConfig, disabledAgents: ['cursor'] } as LocalConfig); + + expect(await fse.readFile(path.join(projectRoot, '.cursor', 'mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); + }); + it('keeps the line of a file tools of different formats share while a stale entry sits under any of their keys', async () => { // Cursor (mcpServers) and OpenCode (mcp) both on .mcp.json, OpenCode last: judged in one format, the other hides. const toolPaths = { diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 94955a9a1..12fbd7c38 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -596,10 +596,13 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise record.unnoted)) - && (await unclaimedMcpServers(target, owned.map((record) => record.name))).length > 0) + // No managed-mcp.json at all, no record for this installed tool the team maps, or a record a pull wrote + // without one whose note hasn't landed: any server no record claims may be teamai's, as pull judges it. + const claimed = targets.filter((t) => t.file === target.file) + .flatMap((t) => manifest?.[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); + const unrecorded = !target.builtinFallback && !target.undetected && manifest[managedMcpManifestKey(target.tool, true)] === undefined; + if (((Object.keys(manifest).length === 0 || unrecorded || owned.some((record) => record.unnoted)) + && (await unclaimedMcpServers(target, claimed)).length > 0) || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, desired)) await hold(target.file); } // And a file a pull wrote under a mapping the team has since changed, but one recorded as tracked while git diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 897ed8f70..cd2bd5f61 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -230,6 +230,8 @@ export interface McpTarget { * elsewhere or not at all. No mapping of today's reaches it for this tool. */ builtinFallback?: true; + /** Added by `includeUndetected`: a tool not installed on this machine, so no pull of this checkout delivers to it. */ + undetected?: true; } /** @@ -279,12 +281,17 @@ export async function resolveMcpTargets( const probe = paths.skills ?? paths.settings ?? paths.agents; if (!probe) continue; - if (!options.includeUndetected && !await isToolInstalledForConfig(tool, probe, localConfig, file)) { + const installed = await isToolInstalledForConfig(tool, probe, localConfig, file); + if (!options.includeUndetected && !installed) { log.debug(`Skipping MCP sync for ${tool}: tool not installed`); continue; } - targets.push({ tool, format, file, projectScope, ...builtinFallback ? { builtinFallback: true as const } : {} }); + targets.push({ + tool, format, file, projectScope, + ...builtinFallback ? { builtinFallback: true as const } : {}, + ...installed ? {} : { undetected: true as const }, + }); } return targets; } @@ -1110,8 +1117,11 @@ async function protectProjectMcpConfigs( // a server no record claims may be one teamai wrote. Noted after the settle, as a rebuild of a lost record // notes the servers it did not write. const lost = Object.keys(before ?? manifest).length === 0; + // So, too, an installed tool the team maps there whose record alone is missing (lost, or never written); + // one this machine doesn't have was never delivered to by a pull here. const unnoted = (file: string): boolean => lost || targets.some((t) => t.file === file - && [before, manifest].some((m) => m?.[managedMcpManifestKey(t.tool, true)]?.some((record) => record.unnoted))); + && ((!t.builtinFallback && !t.undetected && (before ?? manifest)[managedMcpManifestKey(t.tool, true)] === undefined) + || [before, manifest].some((m) => m?.[managedMcpManifestKey(t.tool, true)]?.some((record) => record.unnoted)))); const unclaimed = new Map(); const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => { // One file two tools map: what either's record claims. From 9ba907ceac44bc4cdca1654f22c3e7ade30e8425 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 16:54:19 +0200 Subject: [PATCH 62/85] fix(mcp): note the unclaimed servers under every format of a shared config, and pin an uninstalled tool's leftover config (#882) --- src/__tests__/mcp-reconcile.test.ts | 47 +++++++++++++++++++++++++++++ src/mcp-reconcile.ts | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 3c1607d90..62bef18e6 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -940,6 +940,53 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.cursor\/mcp\.json$/m); }); + it('lists again the config an uninstalled tool left, its record lost, while it holds a server no record claims', async () => { + // OpenCode's config sits outside its root: uninstalled (.opencode gone), opencode.json stays. + const withOpencode = { ...teamConfig, toolPaths: { ...TOOL_PATHS, opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json' } } } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + await writeMcpYaml(`${withSecret} tools: [opencode]\n`); + await reconcileMcpForConfig(withOpencode, projectConfig); + const opencodeFile = path.join(projectRoot, 'opencode.json'); + expect(await fse.readFile(opencodeFile, 'utf-8')).toContain('super-secret-value'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record; + delete manifest['opencode:project']; + await fse.writeJson(manifestFile, manifest); + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + await fse.remove(resolvedMcpFilesPath(projectConfig) ?? ''); + await fse.remove(path.join(projectRoot, '.opencode')); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n'); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(withOpencode, projectConfig); + + expect(await fse.readFile(opencodeFile, 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/opencode\.json$/m); + }); + + it('notes the unclaimed servers under each format of a file tools of different formats share', async () => { + const toolPaths = { + ...UNMOVED_TOOL_PATHS, + cursor: { ...TOOL_PATHS.cursor, mcpProject: '.mcp.json' }, + opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: '.mcp.json' }, + }; + const shared = { ...teamConfig, toolPaths } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + mcpServers: { 'stale-cursor': { type: 'http', url: 'https://a.example/mcp' } }, + mcp: { 'stale-opencode': { type: 'remote', url: 'https://b.example/mcp' } }, + }); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'); + + await reconcileMcpForConfig(shared, projectConfig); + + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + const unverified = (await readResolvedMcpFiles(projectConfig)).files[path.join(projectRoot, '.mcp.json')]?.unverified ?? []; + expect(unverified).toEqual(expect.arrayContaining(['stale-cursor', 'stale-opencode'])); + }); + it('keeps the line of a file tools of different formats share while a stale entry sits under any of their keys', async () => { // Cursor (mcpServers) and OpenCode (mcp) both on .mcp.json, OpenCode last: judged in one format, the other hides. const toolPaths = { diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index cd2bd5f61..8899b207c 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1128,7 +1128,8 @@ async function protectProjectMcpConfigs( const claimed = targets.filter((t) => t.file === target.file) .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); const names = unnoted(target.file) ? await unclaimedMcpServers(target, claimed) : []; - if (names.length > 0) unclaimed.set(target.file, names); + // Tools of different formats sharing the file each find their own: every one is noted. + if (names.length > 0) unclaimed.set(target.file, [...new Set([...unclaimed.get(target.file) ?? [], ...names])]); return names.length > 0 || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, ctx) !== null; }; From 6e5f65941b329e51150957a8625642a07552b51d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 17:05:30 +0200 Subject: [PATCH 63/85] fix(mcp): count an uninstalled tool's missing record when no installed tool maps its file, and name a re-including .gitignore rule on a dry run (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 14 ++++++++++++++ src/__tests__/mcp-git-exclude.test.ts | 12 ++++++++++++ src/__tests__/mcp-reconcile.test.ts | 3 ++- src/doctor-delivery.ts | 4 ++-- src/mcp-git-exclude.ts | 13 ++++++++++--- src/mcp-reconcile.ts | 16 +++++++++++++--- 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index c345ada8a..9cc9da8c4 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -630,6 +630,20 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await check.check()).toBe(false); }); + it('fails the same way for the config an uninstalled tool left, its record lost, though another tool\'s is there', async () => { + teamConfig.toolPaths = { ...teamConfig.toolPaths, opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json' } }; + await fse.outputJson(path.join(projectRoot, 'opencode.json'), { mcp: { stale: { type: 'remote', url: 'https://stale.example/mcp' } } }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('claude', true)]: [{ name: 'docs', hash: 'fixture-hash', resolved: false }], + }); + await writeTeamMcp('servers:\n - name: docs\n transport: http\n url: https://docs.example/mcp\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix ?? '').toContain(path.join(projectRoot, 'opencode.json')); + }); + it('fails the same way while the record a pull wrote without managed-mcp.json is still marked unnoted', async () => { teamConfig.toolPaths = { ...teamConfig.toolPaths, codebuddy: { skills: '.codebuddy/skills', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' } }; await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index aaae8ca11..e17bb895b 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -212,6 +212,18 @@ describe('teamai block in .git/info/exclude (#882)', () => { }); }); + it('names the rule on a dry run too, before any line is written', async () => { + const file = path.join(repo, '.mcp.json'); + const gitignore = path.join(await fse.realpath(repo), '.gitignore'); + + expect(await ensureExcludedFromGit(file, { dryRun: true })).toEqual({ + kind: 'failed', + reason: `a rule in your git ignore files re-includes ${file}: \`!/.mcp.json\` (${gitignore}:2)`, + fix: `Remove \`!/.mcp.json\` from ${gitignore}, then run \`teamai pull\` again.`, + }); + expect(await fse.readFile(path.join(repo, '.git', 'info', 'exclude'), 'utf-8').catch(() => '')).not.toContain('teamai'); + }); + it('says so when git cannot name the rule', async () => { failVerboseCheckIgnore.on = true; const file = path.join(repo, '.mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 62bef18e6..6bd33c6bf 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -944,7 +944,8 @@ servers: // OpenCode's config sits outside its root: uninstalled (.opencode gone), opencode.json stays. const withOpencode = { ...teamConfig, toolPaths: { ...TOOL_PATHS, opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json' } } } as TeamaiConfig; await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); - await writeMcpYaml(`${withSecret} tools: [opencode]\n`); + // Claude's record stays: only OpenCode's is lost. + await writeMcpYaml(`${withSecret} tools: [opencode]\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n`); await reconcileMcpForConfig(withOpencode, projectConfig); const opencodeFile = path.join(projectRoot, 'opencode.json'); expect(await fse.readFile(opencodeFile, 'utf-8')).toContain('super-secret-value'); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 12fbd7c38..00c6e0c72 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -560,7 +560,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise t.file === target.file) .flatMap((t) => manifest?.[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); - const unrecorded = !target.builtinFallback && !target.undetected && manifest[managedMcpManifestKey(target.tool, true)] === undefined; + const unrecorded = unrecordedMcpTool(target, targets) && manifest[managedMcpManifestKey(target.tool, true)] === undefined; if (((Object.keys(manifest).length === 0 || unrecorded || owned.some((record) => record.unnoted)) && (await unclaimedMcpServers(target, claimed)).length > 0) || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, desired)) await hold(target.file); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 0efc7ad53..fb94efaf6 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -238,7 +238,11 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo let result: ExcludeUpdate; try { if (options.dryRun) { - if (add((await readFileSafe(excludeFile)) ?? '') !== null) return { kind: 'pending' }; + if (add((await readFileSafe(excludeFile)) ?? '') !== null) { + // A negated rule in a .gitignore outranks .git/info/exclude: the line would change nothing. + const rule = await reincludingRule(landed); + return rule && path.basename(rule.source) === '.gitignore' ? reincluded(await gitPathOf(file), rule) : { kind: 'pending' }; + } result = 'unchanged'; } else { result = await updateFileLocked(excludeFile, add); @@ -256,8 +260,11 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); if ((await gitTracking(file)).kind !== 'would-commit') return { kind: 'excluded', added: result === 'written' }; // Untracked, as checked above: a rule git reads after teamai's line, or before it, re-includes the file. - const named = await gitPathOf(file); - const rule = await reincludingRule(landed); + return reincluded(await gitPathOf(file), await reincludingRule(landed)); +} + +/** The failure for a file a rule of the member's re-includes, naming `rule` when git could. */ +function reincluded(named: { label: string }, rule: { source: string; line: string; pattern: string } | null): GitExclusion { return rule ? { kind: 'failed', diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 8899b207c..01143c3f9 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -296,6 +296,15 @@ export async function resolveMcpTargets( return targets; } +/** + * Whether a missing record of `target`'s tool makes its file's unclaimed servers suspect (#882): a tool the + * team maps there, installed, or not installed while no installed tool maps that file. + */ +export function unrecordedMcpTool(target: McpTarget, targets: McpTarget[]): boolean { + if (target.builtinFallback) return false; + return !target.undetected || !targets.some((other) => other.file === target.file && !other.undetected); +} + /** * The built-in fallbacks among `targets` their own tool's current mapping does * not reach (#882): the team moved or dropped the tool, so its manifest @@ -1117,10 +1126,11 @@ async function protectProjectMcpConfigs( // a server no record claims may be one teamai wrote. Noted after the settle, as a rebuild of a lost record // notes the servers it did not write. const lost = Object.keys(before ?? manifest).length === 0; - // So, too, an installed tool the team maps there whose record alone is missing (lost, or never written); - // one this machine doesn't have was never delivered to by a pull here. + // So, too, a tool the team maps there whose record alone is missing (lost, or never written): an installed + // one, or one uninstalled since that left the file behind, when no installed tool maps that file (CodeBuddy + // never installed beside Claude's .mcp.json would otherwise hold every member's own servers there). const unnoted = (file: string): boolean => lost || targets.some((t) => t.file === file - && ((!t.builtinFallback && !t.undetected && (before ?? manifest)[managedMcpManifestKey(t.tool, true)] === undefined) + && ((unrecordedMcpTool(t, targets) && (before ?? manifest)[managedMcpManifestKey(t.tool, true)] === undefined) || [before, manifest].some((m) => m?.[managedMcpManifestKey(t.tool, true)]?.some((record) => record.unnoted)))); const unclaimed = new Map(); const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => { From 00169ad713607196dc52caf81fcd09800213c853 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 17:26:05 +0200 Subject: [PATCH 64/85] fix(mcp): scope a shared config's claims to the tools reading the same key, and settle its notes on every format's view (#882) --- src/__tests__/mcp-reconcile.test.ts | 22 ++++++++++++++++++++++ src/__tests__/mcp-resolved-files.test.ts | 15 +++++++++++++++ src/doctor-delivery.ts | 3 ++- src/mcp-reconcile.ts | 6 ++++-- src/mcp-resolved-files.ts | 15 +++++++++++---- src/resources/mcp-format.ts | 6 ++++++ 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 6bd33c6bf..ab04f6199 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -967,6 +967,28 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/opencode\.json$/m); }); + it('never lets one format\'s record claim a server of the same name under another format\'s key', async () => { + const toolPaths = { + ...UNMOVED_TOOL_PATHS, + cursor: { ...TOOL_PATHS.cursor, mcpProject: '.mcp.json' }, + opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: '.mcp.json' }, + }; + const shared = { ...teamConfig, toolPaths } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + // Cursor owns x under mcpServers, now a literal; OpenCode's x under mcp still holds a token, its record lost. + await writeMcpYaml('servers:\n - name: x\n transport: http\n url: https://example.com/x\n tools: [cursor]\n'); + await reconcileMcpForConfig(shared, projectConfig); + const doc = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + ...doc, mcp: { x: { type: 'remote', url: 'https://example.com/x', headers: { Authorization: 'Bearer stale-token-value' } } }, + }); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(shared, projectConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('notes the unclaimed servers under each format of a file tools of different formats share', async () => { const toolPaths = { ...UNMOVED_TOOL_PATHS, diff --git a/src/__tests__/mcp-resolved-files.test.ts b/src/__tests__/mcp-resolved-files.test.ts index cb961c7de..2845287bf 100644 --- a/src/__tests__/mcp-resolved-files.test.ts +++ b/src/__tests__/mcp-resolved-files.test.ts @@ -175,6 +175,21 @@ describe('managed-mcp-files.json', () => { expect((await readResolvedMcpFiles(cfg)).files[cursor()]).toEqual({ tools: ['cursor'], unverified: ['mine'] }); }); + it('settles a file tools of different formats share on what all of them see, not each alone', async () => { + // Cursor sees no server under mcpServers and owns wiki there; OpenCode sees jira and wiki under mcp. + await settleResolvedMcpFiles(cfg, [ + { file: cursor(), tool: 'cursor', state: { kind: 'parsed', servers: ['wiki'] }, holding: false, owned: ['wiki'] }, + { file: cursor(), tool: 'opencode', state: { kind: 'parsed', servers: ['jira', 'wiki'] }, holding: true, owned: [] }, + ]); + expect((await readResolvedMcpFiles(cfg)).files[cursor()]).toEqual({ tools: ['cursor'], unverified: ['jira', 'wiki'] }); + + await settleResolvedMcpFiles(cfg, [ + { file: cursor(), tool: 'cursor', state: { kind: 'parsed', servers: [] }, holding: false, owned: [] }, + { file: cursor(), tool: 'opencode', state: { kind: 'parsed', servers: ['jira'] }, holding: true, owned: [] }, + ]); + expect((await readResolvedMcpFiles(cfg)).files[cursor()]).toEqual({ tools: ['cursor'], unverified: ['jira'] }); + }); + it('lists a file holding a resolved value it did not know of', async () => { const other = path.join(tmp, 'project', '.mcp.json'); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 00c6e0c72..3fd4d7c15 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -564,6 +564,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise t.file === target.file) + const claimed = targets.filter((t) => t.file === target.file && sameServerKey(t.format, target.format)) .flatMap((t) => manifest?.[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); const unrecorded = unrecordedMcpTool(target, targets) && manifest[managedMcpManifestKey(target.tool, true)] === undefined; if (((Object.keys(manifest).length === 0 || unrecorded || owned.some((record) => record.unnoted)) diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 01143c3f9..9b1d8ae8f 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -30,6 +30,7 @@ import { referencedVars, entryHash, MCP_SERVER_KEY, + sameServerKey, type McpFormat, } from './resources/mcp-format.js'; import { mcpEntryReader, teamMcpToDef } from './resources/mcp.js'; @@ -1134,8 +1135,9 @@ async function protectProjectMcpConfigs( || [before, manifest].some((m) => m?.[managedMcpManifestKey(t.tool, true)]?.some((record) => record.unnoted)))); const unclaimed = new Map(); const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => { - // One file two tools map: what either's record claims. - const claimed = targets.filter((t) => t.file === target.file) + // One file two tools map under one key: what either's record claims. A tool that reads another key of the + // file (OpenCode's `mcp` beside `mcpServers`) proves nothing of this one's. + const claimed = targets.filter((t) => t.file === target.file && sameServerKey(t.format, target.format)) .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); const names = unnoted(target.file) ? await unclaimedMcpServers(target, claimed) : []; // Tools of different formats sharing the file each find their own: every one is noted. diff --git a/src/mcp-resolved-files.ts b/src/mcp-resolved-files.ts index bb5a0b1f8..c34311880 100644 --- a/src/mcp-resolved-files.ts +++ b/src/mcp-resolved-files.ts @@ -179,7 +179,14 @@ export function settleResolvedMcpFiles( const { files } = sidecar; let changed = options.earlierMappingsRead === true && sidecar.earlierMappingsRead !== true; if (changed) sidecar.earlierMappingsRead = true; - for (const { file, tool, state, holding, owned, tracked, remapped } of observations) { + // Tools of different formats read different keys of one file: it is empty only when every one of them + // finds it so, and a noted server stays while any of them finds it and does not own it. + const ofFile = (file: string): McpFileObservation[] => observations.filter((o) => o.file === file); + const empty = (file: string): boolean => ofFile(file).every(({ state: s }) => s.kind === 'missing' || (s.kind === 'parsed' && s.servers.length === 0)); + const unparsable = (file: string): boolean => ofFile(file).some(({ state: s }) => s.kind === 'unparsable'); + const stillNoted = (file: string, name: string): boolean => + ofFile(file).some(({ state: s, owned: o }) => s.kind === 'parsed' && s.servers.includes(name) && !o.includes(name)); + for (const { file, tool, holding, tracked, remapped } of observations) { const entry = files[file]; if (tracked === true) { if (entry?.tools.includes(tool)) continue; @@ -187,7 +194,7 @@ export function settleResolvedMcpFiles( changed = true; continue; } - if (state.kind === 'missing' || (state.kind === 'parsed' && state.servers.length === 0)) { + if (empty(file)) { const forget = entry !== undefined && (entry.tracked !== true || tracked === false); if (forget) delete files[file]; changed ||= forget; @@ -216,8 +223,8 @@ export function settleResolvedMcpFiles( changed ||= holding; continue; } - if (state.kind !== 'parsed' || !entry.unverified) continue; - const unverified = entry.unverified.filter((name) => state.servers.includes(name) && !owned.includes(name)); + if (unparsable(file) || !entry.unverified) continue; + const unverified = entry.unverified.filter((name) => stillNoted(file, name)); if (unverified.length === entry.unverified.length) continue; if (unverified.length > 0) entry.unverified = unverified; else delete entry.unverified; diff --git a/src/resources/mcp-format.ts b/src/resources/mcp-format.ts index e29345684..edad86e07 100644 --- a/src/resources/mcp-format.ts +++ b/src/resources/mcp-format.ts @@ -47,6 +47,12 @@ export const MCP_SERVER_KEY: Record, string> = { copilot: 'mcpServers', }; +/** Whether two formats keep their servers under one key of a shared file (Claude, Cursor and CodeBuddy all use `mcpServers`). */ +export function sameServerKey(a: McpFormat, b: McpFormat): boolean { + if (a === 'codex' || b === 'codex') return a === b; + return MCP_SERVER_KEY[a] === MCP_SERVER_KEY[b]; +} + /** Transports each format can actually express. */ const SUPPORTED_TRANSPORTS: Record> = { claude: new Set(['stdio', 'http', 'sse']), From 2033b5beb0ce411da6dd5533d9d809a6053e4b09 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 17:37:06 +0200 Subject: [PATCH 65/85] fix(mcp): keep suspect an uninstalled tool managed-mcp-files.json lists as a writer, though an installed tool maps the file (#882) --- src/__tests__/mcp-reconcile.test.ts | 22 ++++++++++++++++++++++ src/doctor-delivery.ts | 2 +- src/mcp-reconcile.ts | 10 ++++++---- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index ab04f6199..599726344 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -967,6 +967,28 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/opencode\.json$/m); }); + it('keeps suspect a tool managed-mcp-files.json lists as a writer, uninstalled since, though an installed tool maps the file', async () => { + const unmoved = { ...teamConfig, toolPaths: UNMOVED_TOOL_PATHS } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await writeMcpYaml(`${withSecret} tools: [codebuddy]\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n`); + await reconcileMcpForConfig(unmoved, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile) as Record; + delete manifest['codebuddy:project']; + await fse.writeJson(manifestFile, manifest); + await fse.remove(path.join(projectRoot, '.codebuddy')); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + await writeMcpYaml('servers:\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(unmoved, projectConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('never lets one format\'s record claim a server of the same name under another format\'s key', async () => { const toolPaths = { ...UNMOVED_TOOL_PATHS, diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 3fd4d7c15..9b1d5d744 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -601,7 +601,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise t.file === target.file && sameServerKey(t.format, target.format)) .flatMap((t) => manifest?.[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); - const unrecorded = unrecordedMcpTool(target, targets) && manifest[managedMcpManifestKey(target.tool, true)] === undefined; + const unrecorded = unrecordedMcpTool(target, targets, ledger[target.file]?.tools) && manifest[managedMcpManifestKey(target.tool, true)] === undefined; if (((Object.keys(manifest).length === 0 || unrecorded || owned.some((record) => record.unnoted)) && (await unclaimedMcpServers(target, claimed)).length > 0) || await resolvedValueEvidence(target, teamDefs, { owned, unverified: ledger[target.file]?.unverified }, vars, desired)) await hold(target.file); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 9b1d8ae8f..eac041430 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -299,11 +299,13 @@ export async function resolveMcpTargets( /** * Whether a missing record of `target`'s tool makes its file's unclaimed servers suspect (#882): a tool the - * team maps there, installed, or not installed while no installed tool maps that file. + * team maps there, installed, or not installed while no installed tool maps that file or while + * managed-mcp-files.json lists it as having written a resolved value there (`writers`). */ -export function unrecordedMcpTool(target: McpTarget, targets: McpTarget[]): boolean { +export function unrecordedMcpTool(target: McpTarget, targets: McpTarget[], writers: readonly string[] = []): boolean { if (target.builtinFallback) return false; - return !target.undetected || !targets.some((other) => other.file === target.file && !other.undetected); + return !target.undetected || writers.includes(target.tool) + || !targets.some((other) => other.file === target.file && !other.undetected); } /** @@ -1131,7 +1133,7 @@ async function protectProjectMcpConfigs( // one, or one uninstalled since that left the file behind, when no installed tool maps that file (CodeBuddy // never installed beside Claude's .mcp.json would otherwise hold every member's own servers there). const unnoted = (file: string): boolean => lost || targets.some((t) => t.file === file - && ((unrecordedMcpTool(t, targets) && (before ?? manifest)[managedMcpManifestKey(t.tool, true)] === undefined) + && ((unrecordedMcpTool(t, targets, ledger[t.file]?.tools) && (before ?? manifest)[managedMcpManifestKey(t.tool, true)] === undefined) || [before, manifest].some((m) => m?.[managedMcpManifestKey(t.tool, true)]?.some((record) => record.unnoted)))); const unclaimed = new Map(); const holds = async (target: McpTarget, owned: ManagedMcpRecord[]): Promise => { From 4d01d7fedd4bae0eda3f03aad3436ea68c00cef8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 17:49:25 +0200 Subject: [PATCH 66/85] fix(mcp): judge a moved tool's file by the records of the tools reading the same key today (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 20 +++++++++ src/__tests__/mcp-reconcile.test.ts | 20 +++++++++ src/doctor-delivery.ts | 10 ++--- src/mcp-reconcile.ts | 52 +++++++++++++++++------ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 9cc9da8c4..24e78a2c5 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -348,6 +348,26 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).not.toContain(path.join(projectRoot, '.mcp.json')); }); + it('fails for a moved tool\'s file while the tool mapping it today owns that server name only under another key', async () => { + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + teamConfig.toolPaths = { ...teamConfig.toolPaths, opencode: { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: 'shared/mcp.json' } }; + const shared = path.join(projectRoot, 'shared', 'mcp.json'); + await fse.outputJson(shared, { + mcpServers: { x: { type: 'http', url: 'https://x.example/mcp', headers: { Authorization: 'Bearer t0ken-of-cursor' } } }, + mcp: { x: { type: 'remote', url: 'https://x.example/mcp' } }, + }); + expect(await trackResolvedMcpFiles(localConfig, [{ tool: 'cursor', file: shared }])).toBe('written'); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('opencode', true)]: [{ name: 'x', hash: 'fixture-hash', resolved: false }], + }); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix ?? '').toContain(shared); + }); + describe('a config an older teamai wrote under a mapping an earlier teamai.yaml made, before a pull on this version', () => { const old = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); const commitTeamYaml = (toolPaths: object): void => { diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 599726344..9f9ee5cf3 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -989,6 +989,26 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('never lets a tool that maps a moved tool\'s file today claim its stale server under another key', async () => { + const opencode = { skills: '.opencode/skills', mcp: '.config/opencode/opencode.json', mcpProject: '.mcp.json' }; + const before = { ...teamConfig, toolPaths: { ...TOOL_PATHS, cursor: { ...TOOL_PATHS.cursor, mcpProject: '.mcp.json' }, opencode } } as TeamaiConfig; + const after = { ...teamConfig, toolPaths: { ...TOOL_PATHS, opencode } } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + const open = ' - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'; + await writeMcpYaml(`servers:\n - name: x\n transport: http\n url: https://example.com/x\n headers:\n Authorization: Bearer \${SECRET_TOKEN}\n tools: [cursor]\n${open}`); + await reconcileMcpForConfig(before, projectConfig); + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + // Cursor moves back to .cursor/mcp.json; OpenCode, on .mcp.json, now owns a literal x under `mcp`. + await writeMcpYaml(`servers:\n - name: x\n transport: http\n url: https://example.com/x\n tools: [opencode]\n${open}`); + vi.stubEnv('SECRET_TOKEN', ''); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(after, projectConfig); + + expect(await fse.readFile(path.join(projectRoot, '.mcp.json'), 'utf-8')).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('never lets one format\'s record claim a server of the same name under another format\'s key', async () => { const toolPaths = { ...UNMOVED_TOOL_PATHS, diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 9b1d5d744..b17ba2492 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -560,7 +560,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); - if (await recordedMcpFileEvidence(group, owned)) await hold(file); + if (await recordedMcpFileEvidence(group, ownedByMappers(mappedBy, manifest))) await hold(file); } // And, until a pull on this version reads them, those an older teamai wrote under a mapping an earlier // teamai.yaml made. Read-only: the record of that read is pull's. Unreadable history skips them. @@ -624,9 +622,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); - if (await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired, owned)) await hold(target.file); + if (await earlierMappedMcpFileEvidence(target, teamDefs, vars, desired, ownedByMappers(mappedBy, manifest))) await hold(target.file); } if (holding.size === 0) return []; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index eac041430..8d8d05c65 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -774,7 +774,7 @@ async function mcpFileState(targets: McpTarget[]): Promise { +export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: McpOwnedFor): Promise { const state = await mcpFileState(targets); if (state.kind === 'unparsable') return 'it does not parse'; if (state.kind !== 'parsed') return null; @@ -783,10 +783,31 @@ export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: read ? 'teamai may have written a resolved value to it under an earlier toolPaths mapping, and it still holds MCP servers' : null; } - const other = state.servers.find((name) => !owned.includes(name)); - return other === undefined ? null - : `teamai may have written a resolved value to it for ${targets.map((t) => t.tool).join(', ')} under an earlier toolPaths mapping, ` - + `and it holds ${other}, which no tool that maps it now owns`; + // Each target's key read alone: another key's owner proves nothing of it (OpenCode's `mcp` beside `mcpServers`). + for (const target of targets) { + const own = await mcpFileState([target]); + const names = own.kind === 'parsed' ? own.servers : []; + const other = names.find((name) => !owned(target).includes(name)); + if (other !== undefined) { + return `teamai may have written a resolved value to it for ${targets.map((t) => t.tool).join(', ')} under an earlier toolPaths mapping, ` + + `and it holds ${other}, which no tool that maps it now owns`; + } + } + return null; +} + +/** For a target of a file other tools map today, the servers their records own under its key. */ +export type McpOwnedFor = (target: McpTarget) => readonly string[]; + +/** + * `McpOwnedFor` from `mappedBy`, the tools a file's mapping reaches today: only the records of those that + * keep their servers under the judged target's key count (#882). Undefined when no tool maps it today. + */ +export function ownedByMappers(mappedBy: readonly string[], manifest: ManagedMcpManifest | undefined): McpOwnedFor | undefined { + if (mappedBy.length === 0) return undefined; + return (target) => mappedBy + .filter((tool) => { const format = detectMcpFormat(tool); return format !== null && sameServerKey(format, target.format); }) + .flatMap((tool) => manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); } /** @@ -801,7 +822,7 @@ export async function earlierMappedMcpFileEvidence( teamDefs: McpServerDef[] | null, vars: Record, ctx: () => Promise, - owned?: readonly string[], + owned?: McpOwnedFor, ): Promise { return await recordedMcpFileEvidence([target], owned) ?? await resolvedValueEvidence(target, teamDefs, { owned: [] }, vars, ctx); } @@ -827,12 +848,11 @@ async function observeMcpConfigs( for (const [file, { targets: group, mappedBy, tracked }] of await recordedMcpTargets(localConfig, targets)) { const state = await mcpFileState(group); const stillTracked = tracked && (await gitTracks(file)).kind === 'tracked'; - const owned = mappedBy.length === 0 ? undefined - : mappedBy.flatMap((tool) => manifest[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + const owned = ownedByMappers(mappedBy, manifest); const holding = !stillTracked && await recordedMcpFileEvidence(group, owned) !== null; - for (const { tool } of group) { + for (const target of group) { observations.push({ - file, tool, state, holding, owned: owned ?? [], + file, tool: target.tool, state, holding, owned: owned ? [...owned(target)] : [], ...tracked ? { tracked: stillTracked } : {}, ...owned && !stillTracked ? { remapped: true as const } : {}, }); @@ -915,6 +935,8 @@ export async function mcpConfigsNotProvenClean( mappers: Set; mapsToday: Set; proven: Set; writers: Set; /** Every tool's target on this file: tools of different formats read different keys of it. */ all: McpTarget[]; + /** What each of those tools' records own there, by format. */ + ownedByFormat: Array<{ format: McpFormat; names: string[] }>; }>(); const realRoot = (root: string | undefined): Promise => root ? fse.realpath(root).catch(() => root) : Promise.resolve(undefined); @@ -965,6 +987,7 @@ export async function mcpConfigsNotProvenClean( proven, writers, all: [...seen?.all ?? [], target], + ownedByFormat: [...seen?.ownedByFormat ?? [], { format: target.format, names: owned.map((record) => record.name) }], foreign: foreign || seen?.foreign === true, }); } @@ -1004,7 +1027,9 @@ export async function mcpConfigsNotProvenClean( continue; } const moved = remapped.get(file); - const movedWhy = moved && await recordedMcpFileEvidence(moved, targets.get(file)?.owned.map((record) => record.name) ?? []); + const mappedHereNow = targets.get(file); + const movedWhy = moved && await recordedMcpFileEvidence(moved, (target) => (mappedHereNow?.ownedByFormat ?? []) + .filter((o) => sameServerKey(o.format, target.format)).flatMap((o) => o.names)); if (movedWhy) { held.set(file, movedWhy); continue; @@ -1165,11 +1190,10 @@ async function protectProjectMcpConfigs( continue; } // In a file other tools map today, their records tell their own servers. - const owned = mappedBy.length === 0 ? undefined - : mappedBy.flatMap((tool) => manifest[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); + const owned = ownedByMappers(mappedBy, manifest); const holding = await earlierMappedMcpFileEvidence(target, teamDefs, vars, ctx, owned) !== null; if (holding) found.push(target.file); - observations.push({ file: target.file, tool: target.tool, state, holding, owned: owned ?? [], ...owned ? { remapped: true as const } : {} }); + observations.push({ file: target.file, tool: target.tool, state, holding, owned: owned ? [...owned(target)] : [], ...owned ? { remapped: true as const } : {} }); } const holding = new Set(observations.filter((o) => o.holding).map((o) => o.file)); const unproven = new Set(observations.filter((o) => !o.holding).map((o) => o.file)); From 174101e8c7505c3f20cbc719b18988d182f0b4ec Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 18:13:45 +0200 Subject: [PATCH 67/85] fix(mcp): read a Copilot project config's bare servers beside the mcpServers another tool added, and remove teamai's there (#882) --- src/__tests__/local-agent-mcp.test.ts | 20 +++++++++++++ src/__tests__/mcp-reconcile.test.ts | 43 +++++++++++++++++++++++++++ src/doctor-delivery.ts | 2 +- src/local-agent.ts | 4 ++- src/mcp-reconcile.ts | 27 +++++++++++++++-- 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 39a797b65..c8d8574bb 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -159,6 +159,26 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(document).toEqual({ 'user-server': userServer }); }); + it('uninstalls a bare Copilot project entry once another tool has written mcpServers into the file', async () => { + const workspacePath = path.join(tmpDir, 'copilot-shared-project'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + const userServer = { type: 'http', url: 'https://user.example.com/mcp' }; + await fse.ensureDir(path.dirname(configFile)); + await fse.writeJson(configFile, { 'user-server': userServer }); + const command = { scope: 'workspace', workspace_path: workspacePath, slug: COPILOT_SERVER, version: '1.0.0' }; + let acks = await runResponse({ + cmds: [{ id: 8995, type: 'install_mcp', ...command, mcp_config: { transport: 'http', url: 'https://copilot.example.com/mcp' } }], + }, 'copilot'); + expect(acks[0].status).toBe('success'); + const other = { mcpServers: { claude: { type: 'http', url: 'https://claude.example.com/mcp' } } }; + await fse.writeJson(configFile, { ...await fse.readJson(configFile), ...other }); + + acks = await runResponse({ cmds: [{ id: 8996, type: 'uninstall_mcp', ...command }] }, 'copilot'); + + expect(acks[0].status).toBe('success'); + expect(await fse.readJson(configFile)).toEqual({ 'user-server': userServer, ...other }); + }); + it('rejects an unmanaged collision in a bare Copilot project map', async () => { const workspacePath = path.join(tmpDir, 'copilot-collision-project'); const configFile = path.join(workspacePath, '.github', 'mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 9f9ee5cf3..b83893533 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1072,6 +1072,49 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + describe('a bare Copilot config another tool then writes mcpServers into (Copilot and Claude on .mcp.json)', () => { + const shared = (): TeamaiConfig => ({ + ...teamConfig, + toolPaths: { ...TOOL_PATHS, copilot: { skills: '.github/skills', mcp: '.copilot/mcp-config.json', mcpProject: '.mcp.json' } }, + } as TeamaiConfig); + const open = 'servers:\n - name: open\n transport: http\n url: https://example.com/open\n tools: [claude]\n'; + + beforeEach(async () => { + await fse.ensureDir(path.join(projectRoot, '.github', 'skills')); + // An empty file reads as Copilot's bare map: its first write stays bare. + await fse.writeFile(path.join(projectRoot, '.mcp.json'), ''); + await writeMcpYaml(`${withSecret} tools: [copilot]\n`); + await reconcileMcpForConfig(shared(), projectConfig); + const first = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + expect(first['with-secret']).toEqual(expect.objectContaining({ headers: { Authorization: 'Bearer super-secret-value' } })); + expect(first.mcpServers).toBeUndefined(); + }); + + it('keeps its line, pull after pull, while Copilot\'s bare entry holds the value beside the mcpServers Claude wrote', async () => { + await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); + // No longer set: only the entry, not a scan for the value, says what the file holds. + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared(), { ...projectConfig, disabledAgents: ['copilot'] } as LocalConfig); + await reconcileMcpForConfig(shared(), { ...projectConfig, disabledAgents: ['copilot'] } as LocalConfig); + + const after = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + expect(after.mcpServers).toEqual({ open: expect.objectContaining({ url: 'https://example.com/open' }) }); + expect(JSON.stringify(after)).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + + it('removes Copilot\'s bare entry once the team drops it, leaving the mcpServers Claude wrote', async () => { + await writeMcpYaml(open); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared(), projectConfig); + + const after = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + expect(after).toEqual({ mcpServers: { open: expect.objectContaining({ url: 'https://example.com/open' }) } }); + }); + }); + describe('a config two tools share (Claude and CodeBuddy on .mcp.json)', () => { const shared = { ...teamConfig, toolPaths: { ...TOOL_PATHS, codebuddy: { ...TOOL_PATHS.codebuddy, mcpProject: '.mcp.json' } } } as TeamaiConfig; const open = ' - name: open\n transport: http\n url: https://example.com/open\n'; diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index b17ba2492..45af7c0ef 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -502,7 +502,7 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise; /** The existing document stores server names directly at the top level. */ bare: boolean; + /** + * A Copilot project file holding `serverKey` as well: the servers at its top level beside it, which a bare + * write left before another tool added the key (#882). Read and removed, never written to. + */ + beside?: Record; } +const SERVER_KEYS = new Set(Object.values(MCP_SERVER_KEY)); + /** * Read a JSON MCP config. Returns null when the file exists but cannot be * parsed — we abandon the injection rather than risk clobbering a file we do @@ -379,7 +386,9 @@ export async function readJsonDoc( const bare = allowBare && !(serverKey in data); const servers = bare ? data : (data[serverKey] as Record) ?? {}; if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) return null; - return { data, servers: { ...servers }, bare }; + const beside = allowBare && !bare ? Object.fromEntries(Object.entries(data).filter(([key, value]) => + !SERVER_KEYS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value))) : {}; + return { data, servers: { ...servers }, bare, ...Object.keys(beside).length > 0 ? { beside } : {} }; } catch { return null; } @@ -574,11 +583,17 @@ export function desiredMcpForTarget( * team's server arrived: the appliers refuse to overwrite an entry teamai does * not own, so an unrelated server of the same name leaves the key there and the * team's definition undelivered. Only the value tells those two apart. + * A Copilot project file's bare servers beside `mcpServers` count as well + * (#882): what the file holds, not only what the tool reads. * * Read-only. An MCP server is an entry inside a tool's config rather than a * file of its own, so this, not a destination path, is what "delivered" means. */ -export async function installedMcpEntries(target: McpTarget): Promise | null> { +export async function installedMcpEntries( + target: McpTarget, + /** Only the servers under the format's key, as the tool reads them: not a Copilot file's bare ones beside it. */ + options: { underKeyOnly?: boolean } = {}, +): Promise | null> { if (target.format === 'codex') { const raw = await readFileSafe(target.file); if (raw === null) return new Map(); @@ -587,7 +602,8 @@ export async function installedMcpEntries(target: McpTarget): Promise]; const allowBare = target.format === 'copilot' && target.projectScope; const doc = await readJsonDoc(target.file, serverKey, allowBare); - return doc === null ? null : new Map(Object.entries(doc.servers)); + if (doc === null) return null; + return new Map([...options.underKeyOnly ? [] : Object.entries(doc.beside ?? {}), ...Object.entries(doc.servers)]); } /** @@ -1588,6 +1604,11 @@ async function applyJson( delete doc.servers[name]; dirty = true; } + // One a bare write left before another tool added the key goes too (#882). + if (doc.beside?.[name] !== undefined) { + delete doc.data[name]; + dirty = true; + } changes.push({ tool: target.tool, server: name, action: 'removed' }); } From f80b871bb9db927ddaef0d3b27e8a9981748152f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 18:18:13 +0200 Subject: [PATCH 68/85] fix(mcp): keep a project config the local agent writes a header or env value to out of git, and have doctor check it for HTTP teams (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 41 ++++++++++++++ src/__tests__/local-agent-mcp.test.ts | 66 +++++++++++++++++++++++ src/doctor-delivery.ts | 43 ++++++++++----- src/local-agent.ts | 42 +++++++++++++-- src/mcp-git-exclude.ts | 16 ++++++ 5 files changed, 192 insertions(+), 16 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 24e78a2c5..265762d4d 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -368,6 +368,47 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix ?? '').toContain(shared); }); + describe('for an HTTP-backed team, judged by the records the local agent wrote', () => { + const writeRecord = (record: Record): Promise => + fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { [managedMcpManifestKey('claude', true)]: [record] }); + + beforeEach(async () => { + Object.assign(localConfig, { repo: { localPath: repoPath, remote: 'https://teamai.example', kind: 'http', url: 'https://teamai.example' } }); + // An HTTP team has no mcp.yaml: its servers arrive through install_mcp. + await fse.remove(path.join(repoPath, 'mcp')); + }); + + it.each([ + ['notes it carried a header or env value', { name: 'jira', hash: 'h', resolved: true }], + ['is an older local agent\'s, without that note, and its entry holds a header', { name: 'jira', hash: 'h' }], + ])('fails while git would track the file, and names it, when the record %s', async (_label, record) => { + await writeRecord(record); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); + // No pull writes an HTTP team's servers, so none lists the file. + expect(check.fix).not.toContain('teamai pull'); + }); + + it('passes once git ignores the file', async () => { + await writeRecord({ name: 'jira', hash: 'h', resolved: true }); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(true); + }); + + it('has nothing to say of a file whose recorded server carries neither header nor env value', async () => { + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp' } } }); + await writeRecord({ name: 'jira', hash: 'h', resolved: false }); + + expect(await excludeCheck()).toBeUndefined(); + }); + }); + describe('a config an older teamai wrote under a mapping an earlier teamai.yaml made, before a pull on this version', () => { const old = (): string => path.join(projectRoot, '.cursor', 'team-mcp.json'); const commitTeamYaml = (toolPaths: object): void => { diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index c8d8574bb..a4d9d40eb 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { execFileSync } from 'node:child_process'; vi.mock('../utils/logger.js', () => ({ log: { @@ -531,6 +532,71 @@ describe('local-agent: MCP install/uninstall commands', () => { ); }); + // A project-scope install carrying a credential lands only in a file git leaves out of a commit (#882). + describe('a workspace install carrying a header or env value, in a git checkout (#882)', () => { + let wsPath: string; + const git = (...args: string[]): string => execFileSync('git', args, { cwd: wsPath, encoding: 'utf-8' }); + const install = (id: number, mcpConfig: Record) => runResponse({ + cmds: [{ + id, type: 'install_mcp', scope: 'workspace', workspace_path: wsPath, slug: 'clawpro', version: '1.0.0', mcp_config: mcpConfig, + }], + }); + const bearer = { transport: 'http', url: 'https://clawpro.example.com/mcp', headers: { Authorization: 'Bearer bmcp-test-token' } }; + const workspaceFile = async (name: string): Promise => { + const wsDir = path.join(wsPath, '.teamai', 'workspaces'); + const ids = await fse.readdir(wsDir); + expect(ids).toHaveLength(1); + return path.join(wsDir, ids[0], name); + }; + + beforeEach(async () => { + wsPath = path.join(tmpDir, 'projects', 'repo-git'); + await fse.ensureDir(path.join(wsPath, '.codebuddy', 'skills')); + git('init', '-q'); + }); + + it.each([ + ['an Authorization header', bearer], + ['a stdio env value', { transport: 'stdio', command: 'clawpro-mcp', env: { CLAWPRO_TOKEN: 'bmcp-test-token' } }], + ])('lists the config in .git/info/exclude before writing %s, and records it in managed-mcp-files.json', async (_label, mcpConfig) => { + const acks = await install(9101, mcpConfig); + + expect(acks[0].status).toBe('success'); + expect(await fse.readFile(path.join(wsPath, '.mcp.json'), 'utf-8')).toContain('bmcp-test-token'); + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toMatch(/^\/\.mcp\.json$/m); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).toBe(''); + const sidecar = await fse.readJson(await workspaceFile('managed-mcp-files.json')) as { files: Record }; + expect(Object.entries(sidecar.files)).toEqual([[expect.stringMatching(/\.mcp\.json$/), { tools: ['codebuddy'] }]]); + const manifest = await fse.readJson(await workspaceFile('managed-mcp.json')); + expect(manifest['codebuddy:project']).toEqual([expect.objectContaining({ name: 'clawpro', resolved: true })]); + }); + + it('withholds it from a config git tracks, naming why, and leaves the file and its records as they were', async () => { + const original = { mcpServers: { mine: { type: 'http', url: 'https://mine.example.com/mcp' } } }; + await fse.writeJson(path.join(wsPath, '.mcp.json'), original); + git('add', '.mcp.json'); + + const acks = await install(9102, bearer); + + expect(acks[0].status).toBe('failed'); + expect(acks[0].error).toContain('git already tracks'); + expect(await fse.readJson(path.join(wsPath, '.mcp.json'))).toEqual(original); + const manifestFile = path.join(wsPath, '.teamai', 'workspaces'); + const manifests = await fse.pathExists(manifestFile) ? await fse.readdir(manifestFile) : []; + for (const id of manifests) { + const manifest = await fse.readJson(path.join(manifestFile, id, 'managed-mcp.json')).catch(() => ({})); + expect(manifest['codebuddy:project']).toBeUndefined(); + } + }); + + it('adds no line for a server with neither header nor env value', async () => { + const acks = await install(9103, { transport: 'http', url: 'https://clawpro.example.com/mcp' }); + + expect(acks[0].status).toBe('success'); + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).not.toMatch(/\.mcp\.json/); + }); + }); + // ─── install_mcp: 缺少 mcp_config 时失败 ────────────────────────── it('install_mcp fails when mcp_config is missing', async () => { const acks = await runResponse({ diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 45af7c0ef..91e2b75b0 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -551,15 +551,17 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise { const { localConfig, teamConfig } = ctx; const { projectRoot } = localConfig; - if (!teamConfig || localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return []; + if (!teamConfig || localConfig.scope !== 'project' || !projectRoot) return []; const { - resolveMcpTargets, resolvedValueEvidence, buildVarTable, buildDesiredMcpContext, recordedMcpTargets, recordedMcpFileEvidence, + resolveMcpTargets, resolvedValueEvidence, buildVarTable, buildDesiredMcpContext, recordedMcpTargets, recordedMcpFileEvidence, installedMcpEntries, earlierMappedMcpTargets, earlierMappedMcpFileEvidence, ownedByMappers, unrecordedMcpTool, unmappedMcpDefaults, unrecordedUnmappedMcpDefaults, unclaimedMcpServers, } = await import('./mcp-reconcile.js'); const { readResolvedMcpFiles } = await import('./mcp-resolved-files.js'); @@ -591,6 +593,29 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise !unmapped.has(target)); + const report = (held: string, next: string): Check[] => holding.size === 0 ? [] : [{ + name: 'Project MCP configs with resolved values are kept out of git', + source: 'local', + check: async () => tracked.length === 0, + fix: `${tracked.join(', ')} may hold ${held}, and git would commit them or cannot say. ${next}`, + }]; + if (localConfig.repo.kind === 'http') { + // No mcp.yaml to judge by: a server its install recorded as carrying a credential, or an older install's + // entry holding a header or env value. Also in a file recorded under a mapping another teamai.yaml made. + const { carriesLocalAgentCredential } = await import('./mcp-git-exclude.js'); + manifest = (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; + const recorded = [...(await recordedMcpTargets(localConfig, targets)).values()].flatMap((file) => file.targets); + for (const target of [...targets, ...recorded]) { + if (holding.has(target.file) || !await pathExists(target.file)) continue; + const installed = await installedMcpEntries(target); + const credential = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).some((record) => installed === null + ? record.resolved !== false + : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))); + if (credential) await hold(target.file); + } + return report('MCP headers or env values in plaintext', 'Fix any git error shown, then add each to .git/info/exclude. If git already tracks one, run ' + + '`git rm --cached ` and rotate the values it held.'); + } for (const target of targets) { if (holding.has(target.file) || !await pathExists(target.file)) continue; manifest ??= (await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true })).manifest; @@ -624,16 +649,8 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise tracked.length === 0, - fix: `${tracked.join(', ')} may hold MCP variables resolved to plaintext, and git would commit them or cannot say. ` - + 'Fix any git error shown, then run `teamai pull` to list them in .git/info/exclude. If git already tracks one, run ' - + '`git rm --cached ` and rotate the values it held.', - }]; + return report('MCP variables resolved to plaintext', 'Fix any git error shown, then run `teamai pull` to list them in .git/info/exclude. If git already tracks one, run ' + + '`git rm --cached ` and rotate the values it held.'); } /** diff --git a/src/local-agent.ts b/src/local-agent.ts index 490e4143d..59ac8c6fc 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2845,13 +2845,16 @@ function updateManifestRecord( key: string, name: string, hash: string, + /** Project scope: whether the entry carries a credential, as `resolved` notes for a pull's (#882). */ + resolved?: boolean, ): void { const records = manifest[key] ?? []; const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); + const record: ManagedMcpRecord = { name, hash, ...resolved === undefined ? {} : { resolved } }; if (idx >= 0) { - records[idx] = { name, hash }; + records[idx] = record; } else { - records.push({ name, hash }); + records.push(record); } manifest[key] = records; } @@ -2938,7 +2941,9 @@ async function installMcpServer( if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); } - updateManifestRecord(manifest, manifestKey, slug, hash); + // A credential lands in a project config only once git leaves the file out of a commit, as a pull's does (#882). + const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); + updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined); await writeJsonAtomic(manifestPath, manifest); doc.servers[slug] = entry; await writeJsonDoc(targetFile, serverKey, doc); @@ -2947,6 +2952,37 @@ async function installMcpServer( return command.version; } +/** + * For a project-scope install: whether `entry` carries a credential and, if + * so, list `file` in `.git/info/exclude` and record it in + * managed-mcp-files.json, as a pull does before writing a resolved value + * (#882). Throws, before anything is written, when git would commit the file. + */ +async function keepCredentialOutOfGit( + localConfig: LocalConfig, + tool: string, + slug: string, + file: string, + entry: unknown, +): Promise { + const { carriesLocalAgentCredential, ensureExcludedFromGit } = await import('./mcp-git-exclude.js'); + if (!carriesLocalAgentCredential(entry)) return false; + const exclusion = await ensureExcludedFromGit(file); + if (exclusion.kind === 'failed') { + throw new Error( + `install_mcp: withheld "${slug}" from ${file}: it carries a header or env value, and teamai could not keep the file ` + + `out of git: ${exclusion.reason}. The file is left as it was. ${exclusion.fix}`, + ); + } + const { trackResolvedMcpFiles } = await import('./mcp-resolved-files.js'); + // A failure does not stop the write: the exclusion protects the file. + const result = await trackResolvedMcpFiles(localConfig, [{ tool, file }]).catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result !== 'written' && result !== 'unchanged') { + log.debug(`Did not record ${file} in managed-mcp-files.json: ${result === 'locked' ? 'another teamai command held it past the wait' : result}.`); + } + return true; +} + async function uninstallMcpServer( config: LocalAgentConfig, tool: string, diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index fb94efaf6..df96aa25b 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -37,6 +37,22 @@ export function carriesResolvedValue( && !supportsEnvExpansion(target.format, target.projectScope, def)); } +/** + * Whether a JSON MCP entry the local agent installs for an HTTP-backed team + * carries a credential (#882): a header or env value of any kind. Its payload + * holds the values themselves, not `${VAR}` references teamai resolves, so + * nothing tells a bearer token from a plain setting: every one counts. + */ +export function carriesLocalAgentCredential(entry: unknown): boolean { + if (typeof entry !== 'object' || entry === null) return false; + const fields = entry as Record; + // OpenCode keeps env under `environment`. + return ['headers', 'env', 'environment'].some((key) => { + const values = fields[key]; + return typeof values === 'object' && values !== null && Object.keys(values).length > 0; + }); +} + /** * The variable whose value, resolved by teamai into `target`, `raw` (a project * file's text) holds, or null: one `teamDefs` references that the tool does not From 6721cf46d8b8a911a3e593ba1db690ed100c4b45 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 18:19:23 +0200 Subject: [PATCH 69/85] fix(mcp): document the local agent's project-scope exclusion and Copilot's bare servers beside mcpServers (#882) --- docs/designs/data-directory-layout.md | 3 ++- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 6 +++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index c46aae221..305982b6a 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -441,7 +441,8 @@ every checkout, so that is where they live now: └── workspaces// ├── managed-mcp.json managedMcpManifestPath, one per checkout ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether - │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882) + │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882); + │ for an HTTP team, the configs the local agent wrote a header or env value to └── search-index.json getProjectSearchIndexPath, one per checkout /.teamai/ one per checkout: committed knowledge, knowledge-wt/ ``` diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 99ffd87e8..8a5a189cc 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1166,7 +1166,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header or env value counts as holding a credential. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry holding a header or env value; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. 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 864dde747..db469dbc7 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1068,7 +1068,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header 或 env 值,就视为含有凭据。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带 header 或 env 值的条目;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 48222080f..7e0075adc 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -71,7 +71,11 @@ and that server noted: it keeps the line until it leaves the file. So is the file of a tool `managed-mcp.json` has no record for, when a pull writes that tool's first record (its record lost, or teamai's first delivery to it). While that note cannot be written (another teamai command holds the record), the line stays -until a later pull writes it. +until a later pull writes it. A Copilot project config's bare top-level servers +still count once another tool writes `mcpServers` into the file. On an HTTP-backed +team the local agent's `install_mcp` lists a project config before writing a +server with any header or env value, fails the install when it cannot, and only +`teamai uninstall` takes that line out; `teamai doctor` checks those files too. ## Invite a member From afbf42c2e10fb16ed3ba7a15944f2ff25133eeef Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 18:27:02 +0200 Subject: [PATCH 70/85] fix(mcp): tell the local agent's withheld install to install the MCP server again, not to pull (#882) --- src/__tests__/local-agent-mcp.test.ts | 3 +++ src/__tests__/mcp-git-exclude.test.ts | 8 ++++++++ src/local-agent.ts | 3 ++- src/mcp-git-exclude.ts | 28 ++++++++++++++++----------- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index a4d9d40eb..b9bfd393d 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -580,6 +580,9 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(acks[0].status).toBe('failed'); expect(acks[0].error).toContain('git already tracks'); + // Commands come from the server: no pull replays one. + expect(acks[0].error).toContain('then install the MCP server again.'); + expect(acks[0].error).not.toContain('teamai pull'); expect(await fse.readJson(path.join(wsPath, '.mcp.json'))).toEqual(original); const manifestFile = path.join(wsPath, '.teamai', 'workspaces'); const manifests = await fse.pathExists(manifestFile) ? await fse.readdir(manifestFile) : []; diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index e17bb895b..fe7f0366a 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -179,6 +179,14 @@ describe('teamai block in .git/info/exclude (#882)', () => { expect(await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : '').not.toContain('teamai'); }); + it('names the caller\'s way to try again in its fix, when given one', async () => { + const file = path.join(repo, '.mcp.json'); + + expect(await ensureExcludedFromGit(file, { dryRun: true, rerun: 'install the MCP server again' })).toMatchObject({ + fix: `Run \`git rm --cached ${file}\` (rotate any value a commit of it holds), then install the MCP server again.`, + }); + }); + it.skipIf(process.getuid?.() === 0).each([ ['a pull', {}], ['a dry run', { dryRun: true }], diff --git a/src/local-agent.ts b/src/local-agent.ts index 59ac8c6fc..ae7e2df3e 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2967,7 +2967,8 @@ async function keepCredentialOutOfGit( ): Promise { const { carriesLocalAgentCredential, ensureExcludedFromGit } = await import('./mcp-git-exclude.js'); if (!carriesLocalAgentCredential(entry)) return false; - const exclusion = await ensureExcludedFromGit(file); + // Commands come from the server: no pull replays one. + const exclusion = await ensureExcludedFromGit(file, { rerun: 'install the MCP server again' }); if (exclusion.kind === 'failed') { throw new Error( `install_mcp: withheld "${slug}" from ${file}: it carries a header or env value, and teamai could not keep the file ` diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index df96aa25b..715e208e4 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -204,18 +204,24 @@ export type GitExclusion = * does not track: an exclude rule does not apply to a tracked file, and a git * error is never read as safe. `file` need not exist yet: pull calls this * before writing a resolved value into it. `dryRun` writes nothing and reports - * what would stop the write. + * what would stop the write. `rerun` ends each fix: how the caller's write is + * tried again. */ -export async function ensureExcludedFromGit(file: string, options: { dryRun?: boolean } = {}): Promise { +export async function ensureExcludedFromGit( + file: string, + options: { dryRun?: boolean; rerun?: string } = {}, +): Promise { + const { rerun = 'run `teamai pull` again' } = options; const tracking = await gitTracking(file); if (tracking.kind === 'ignored' || tracking.kind === 'outside-repo') return { kind: 'excluded', added: false }; - const repair = 'Fix the repository, or add the file to its .git/info/exclude yourself, then run `teamai pull` again.'; + const repair = `Fix the repository, or add the file to its .git/info/exclude yourself, then ${rerun}.`; const tracked = async (): Promise => { const named = await gitPathOf(file); return { kind: 'failed', reason: `git already tracks ${named.label}`, - fix: `Run \`git rm --cached ${named.path}\` (rotate any value a commit of it holds), then \`teamai pull\` again.`, + // After "Run `git rm …`", a second "run" is dropped: "then `teamai pull` again". + fix: `Run \`git rm --cached ${named.path}\` (rotate any value a commit of it holds), then ${rerun.replace(/^run /, '')}.`, }; }; const inIndex = await gitTracks(file); @@ -236,7 +242,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo // Anchored at the working tree root, glob characters escaped. const rel = path.relative(dir, landed).split(path.sep).join('/'); const pattern = `/${location.prefix}${rel}`.replace(/[\\*?[\]!#]/g, '\\$&'); - const retry = `Make it writable, or add \`${pattern}\` to it yourself, then run \`teamai pull\` again.`; + const retry = `Make it writable, or add \`${pattern}\` to it yourself, then ${rerun}.`; // A read-only exclude file is the member's choice; the atomic write would replace it all the same. for (const writable of [path.dirname(excludeFile), ...(await pathExists(excludeFile) ? [excludeFile] : [])]) { const denied = await fse.access(writable, fse.constants.W_OK).then(() => false, () => true); @@ -257,7 +263,7 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo if (add((await readFileSafe(excludeFile)) ?? '') !== null) { // A negated rule in a .gitignore outranks .git/info/exclude: the line would change nothing. const rule = await reincludingRule(landed); - return rule && path.basename(rule.source) === '.gitignore' ? reincluded(await gitPathOf(file), rule) : { kind: 'pending' }; + return rule && path.basename(rule.source) === '.gitignore' ? reincluded(await gitPathOf(file), rule, rerun) : { kind: 'pending' }; } result = 'unchanged'; } else { @@ -270,27 +276,27 @@ export async function ensureExcludedFromGit(file: string, options: { dryRun?: bo return { kind: 'failed', reason: `another teamai command held ${excludeFile} past the wait`, - fix: 'Run `teamai pull` again.', + fix: `${rerun.charAt(0).toUpperCase()}${rerun.slice(1)}.`, }; } if (result === 'written') log.debug(`Added ${pattern} to ${excludeFile}`); if ((await gitTracking(file)).kind !== 'would-commit') return { kind: 'excluded', added: result === 'written' }; // Untracked, as checked above: a rule git reads after teamai's line, or before it, re-includes the file. - return reincluded(await gitPathOf(file), await reincludingRule(landed)); + return reincluded(await gitPathOf(file), await reincludingRule(landed), rerun); } /** The failure for a file a rule of the member's re-includes, naming `rule` when git could. */ -function reincluded(named: { label: string }, rule: { source: string; line: string; pattern: string } | null): GitExclusion { +function reincluded(named: { label: string }, rule: { source: string; line: string; pattern: string } | null, rerun: string): GitExclusion { return rule ? { kind: 'failed', reason: `a rule in your git ignore files re-includes ${named.label}: \`${rule.pattern}\` (${rule.source}:${rule.line})`, - fix: `Remove \`${rule.pattern}\` from ${rule.source}, then run \`teamai pull\` again.`, + fix: `Remove \`${rule.pattern}\` from ${rule.source}, then ${rerun}.`, } : { kind: 'failed', reason: `a rule in your git ignore files re-includes ${named.label}`, - fix: 'Remove the rule in .gitignore, .git/info/exclude or core.excludesFile that re-includes it (`git check-ignore -v` names it), then run `teamai pull` again.', + fix: `Remove the rule in .gitignore, .git/info/exclude or core.excludesFile that re-includes it (\`git check-ignore -v\` names it), then ${rerun}.`, }; } From bca30eb00346cc76b29bf71435ea150ea7a9c93c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 18:45:18 +0200 Subject: [PATCH 71/85] fix(mcp): replace a Copilot server's bare copy when pull or the local agent writes it again under mcpServers (#882) --- src/__tests__/local-agent-mcp.test.ts | 24 ++++++++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 15 +++++++++++++++ src/local-agent.ts | 2 ++ src/mcp-reconcile.ts | 5 +++++ 4 files changed, 46 insertions(+) diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index b9bfd393d..29cd00e78 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -180,6 +180,30 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(await fse.readJson(configFile)).toEqual({ 'user-server': userServer, ...other }); }); + it('replaces a bare Copilot project entry it installs again once another tool has written mcpServers into the file', async () => { + const workspacePath = path.join(tmpDir, 'copilot-reinstall-project'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(path.dirname(configFile)); + await fse.writeJson(configFile, {}); + const command = { scope: 'workspace', workspace_path: workspacePath, slug: COPILOT_SERVER }; + let acks = await runResponse({ + cmds: [{ id: 8997, type: 'install_mcp', ...command, version: '1.0.0', mcp_config: { transport: 'http', url: 'https://old.example.com/mcp' } }], + }, 'copilot'); + expect(acks[0].status).toBe('success'); + const other = { mcpServers: { claude: { type: 'http', url: 'https://claude.example.com/mcp' } } }; + await fse.writeJson(configFile, { ...await fse.readJson(configFile), ...other }); + + acks = await runResponse({ + cmds: [{ id: 8998, type: 'install_mcp', ...command, version: '1.0.1', mcp_config: { transport: 'http', url: 'https://new.example.com/mcp' } }], + }, 'copilot'); + + expect(acks[0].status).toBe('success'); + const doc = await fse.readJson(configFile) as Record; + expect(doc[COPILOT_SERVER]).toBeUndefined(); + expect(JSON.stringify(doc)).not.toContain('old.example.com'); + expect((doc.mcpServers as Record)[COPILOT_SERVER]).toEqual(expect.objectContaining({ url: 'https://new.example.com/mcp' })); + }); + it('rejects an unmanaged collision in a bare Copilot project map', async () => { const workspacePath = path.join(tmpDir, 'copilot-collision-project'); const configFile = path.join(workspacePath, '.github', 'mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index b83893533..76d5b2b54 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1104,6 +1104,21 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('replaces Copilot\'s bare entry when it writes that server again under the mcpServers Claude added', async () => { + await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); + await reconcileMcpForConfig(shared(), projectConfig); + expect((await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record).mcpServers).toBeDefined(); + await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'published-literal')} tools: [copilot]\n${open.replace('servers:\n', '')}`); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared(), projectConfig); + + const after = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + expect(JSON.stringify(after)).not.toContain('super-secret-value'); + expect(after['with-secret']).toBeUndefined(); + expect((after.mcpServers as Record)['with-secret']).toEqual(expect.objectContaining({ headers: { Authorization: 'Bearer published-literal' } })); + }); + it('removes Copilot\'s bare entry once the team drops it, leaving the mcpServers Claude wrote', async () => { await writeMcpYaml(open); vi.stubEnv('SECRET_TOKEN', ''); diff --git a/src/local-agent.ts b/src/local-agent.ts index ae7e2df3e..d876b579d 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2945,6 +2945,8 @@ async function installMcpServer( const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined); await writeJsonAtomic(manifestPath, manifest); + // The copy a bare install left before another tool added the key would keep the old value beside this one (#882). + if (ownedNames.has(slug) && doc.beside?.[slug] !== undefined) delete doc.data[slug]; doc.servers[slug] = entry; await writeJsonDoc(targetFile, serverKey, doc); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 59577df8c..2d4b6f6df 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1592,6 +1592,11 @@ async function applyJson( continue; } nextRecords.push({ name, hash }); + // The copy a bare write left before another tool added the key would keep the old value beside this one (#882). + if (ownedNames.has(name) && doc.beside?.[name] !== undefined) { + delete doc.data[name]; + dirty = true; + } if (existing !== undefined && ownedHash.get(name) === hash) continue; doc.servers[name] = entry; dirty = true; From 4b76d70cc291ebaa9b7096a4b93a93a9d9e8b75f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 18:54:36 +0200 Subject: [PATCH 72/85] fix(mcp): count a local-agent install's arguments, URL user or query and command line as credentials, and scope the rebuild's claims by key (#882) --- docs/designs/data-directory-layout.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 2 +- src/__tests__/mcp-git-exclude.test.ts | 22 +++++++++++++++++- src/mcp-git-exclude.ts | 25 +++++++++++++++------ src/mcp-reconcile.ts | 3 ++- 7 files changed, 45 insertions(+), 13 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 305982b6a..febf09e85 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -442,7 +442,7 @@ every checkout, so that is where they live now: ├── managed-mcp.json managedMcpManifestPath, one per checkout ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882); - │ for an HTTP team, the configs the local agent wrote a header or env value to + │ for an HTTP team, the configs the local agent wrote a credential to └── search-index.json getProjectSearchIndexPath, one per checkout /.teamai/ one per checkout: committed knowledge, knowledge-wt/ ``` diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 8a5a189cc..74befe233 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1166,7 +1166,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header or env value counts as holding a credential. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry holding a header or env value; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header, env value or argument, a URL with a user or a query, or a command line with arguments counts as holding a credential. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry holding a header or env value; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. 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 db469dbc7..fba62a8b0 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1068,7 +1068,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header 或 env 值,就视为含有凭据。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带 header 或 env 值的条目;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header、env 值或参数、带用户名或查询串的 URL,或带参数的命令行,就视为含有凭据。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带 header 或 env 值的条目;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 7e0075adc..f2ca60c41 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -74,7 +74,7 @@ note cannot be written (another teamai command holds the record), the line stays until a later pull writes it. A Copilot project config's bare top-level servers still count once another tool writes `mcpServers` into the file. On an HTTP-backed team the local agent's `install_mcp` lists a project config before writing a -server with any header or env value, fails the install when it cannot, and only +server with any header, env value, argument, or URL user or query, fails the install when it cannot, and only `teamai uninstall` takes that line out; `teamai doctor` checks those files too. ## Invite a member diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index fe7f0366a..fbeaa0454 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -39,7 +39,7 @@ vi.mock('../utils/fs.js', async (importOriginal) => { }; }); -import { MCP_EXCLUDE_END, MCP_EXCLUDE_START, ensureExcludedFromGit, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; +import { MCP_EXCLUDE_END, MCP_EXCLUDE_START, carriesLocalAgentCredential, ensureExcludedFromGit, excludeFromGit, removeMcpGitExclude } from '../mcp-git-exclude.js'; import { acquireLock, releaseLock } from '../update.js'; import { log } from '../utils/logger.js'; @@ -357,3 +357,23 @@ describe('teamai block in .git/info/exclude (#882)', () => { }); }); }); + +describe('a local agent install that carries a credential (#882)', () => { + it.each([ + ['a header', { type: 'http', url: 'https://x.example/mcp', headers: { Authorization: 'Bearer t' } }], + ['an env value', { command: 'npx', env: { TOKEN: 't' } }], + ['an argument', { command: 'npx', args: ['-y', 'server', '--token', 't'] }], + ['a URL with a user', { type: 'http', url: 'https://user:t@x.example/mcp' }], + ['a URL with a query', { type: 'http', url: 'https://x.example/mcp?key=t' }], + ['a whole command line', { command: 'server --token t' }], + ])('counts %s', (_, entry) => { + expect(carriesLocalAgentCredential(entry)).toBe(true); + }); + + it.each([ + ['a plain URL', { type: 'http', url: 'https://x.example/mcp' }], + ['a bare command', { command: 'npx' }], + ])('does not count %s', (_, entry) => { + expect(carriesLocalAgentCredential(entry)).toBe(false); + }); +}); diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 715e208e4..54d974fea 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -39,17 +39,28 @@ export function carriesResolvedValue( /** * Whether a JSON MCP entry the local agent installs for an HTTP-backed team - * carries a credential (#882): a header or env value of any kind. Its payload - * holds the values themselves, not `${VAR}` references teamai resolves, so - * nothing tells a bearer token from a plain setting: every one counts. + * carries a credential (#882): a header, env value or argument of any kind, a + * URL with a user or a query, or a command line with arguments in it. Its + * payload holds the values themselves, not `${VAR}` references teamai + * resolves, so nothing tells a token from a plain setting: every one counts. */ export function carriesLocalAgentCredential(entry: unknown): boolean { if (typeof entry !== 'object' || entry === null) return false; const fields = entry as Record; - // OpenCode keeps env under `environment`. - return ['headers', 'env', 'environment'].some((key) => { - const values = fields[key]; - return typeof values === 'object' && values !== null && Object.keys(values).length > 0; + const nonEmpty = (value: unknown): boolean => + Array.isArray(value) ? value.length > 0 : typeof value === 'object' && value !== null && Object.keys(value).length > 0; + // OpenCode keeps env under `environment`, and a stdio command with its arguments under `command`. + if (['headers', 'env', 'environment', 'args'].some((key) => nonEmpty(fields[key]))) return true; + if (Array.isArray(fields.command) ? fields.command.length > 1 : typeof fields.command === 'string' && /\s/.test(fields.command.trim())) return true; + return ['url', 'serverUrl', 'httpUrl'].some((key) => { + const value = fields[key]; + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.username !== '' || url.password !== '' || url.search !== ''; + } catch { + return false; + } }); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 2d4b6f6df..b16a6cc57 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1484,7 +1484,8 @@ async function reconcileTargets( // unnoted until protectProjectMcpConfigs notes that server, after its settle records the file. for (const target of targets.filter((t) => unrecorded.has(t.tool))) { const records = manifest[managedMcpManifestKey(target.tool, true)] ?? []; - const claimed = targets.filter((t) => t.file === target.file) + // Only tools reading the same key claim: another key's owner proves nothing of this one's (#882). + const claimed = targets.filter((t) => t.file === target.file && sameServerKey(t.format, target.format)) .flatMap((t) => manifest[managedMcpManifestKey(t.tool, true)] ?? []).map((record) => record.name); if (records.length > 0 && (await unclaimedMcpServers(target, claimed)).length > 0) { for (const record of records) record.unnoted = true; From cfffd1a20e5223802b25da41058fbb7fabadcf1a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 19:05:39 +0200 Subject: [PATCH 73/85] fix(mcp): count any URL in a local-agent install as a credential, and have doctor judge a recorded HTTP-team file with no record (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 2 +- src/__tests__/doctor-mcp-delivery.test.ts | 11 +++++++++++ src/__tests__/local-agent-mcp.test.ts | 5 +++-- src/__tests__/mcp-git-exclude.test.ts | 3 ++- src/doctor-delivery.ts | 14 +++++++++++--- src/local-agent.ts | 2 +- src/mcp-git-exclude.ts | 18 +++++------------- 9 files changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 74befe233..91b8611ca 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1166,7 +1166,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header, env value or argument, a URL with a user or a query, or a command line with arguments counts as holding a credential. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry holding a header or env value; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header, env value or argument, a URL (a token can sit in its path), or a command line with arguments counts as holding a credential; only a bare stdio command doesn't. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry carrying one, and, with no record of the tool, a file `managed-mcp-files.json` lists while it holds any server; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. 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 fba62a8b0..609e44c56 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1068,7 +1068,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header、env 值或参数、带用户名或查询串的 URL,或带参数的命令行,就视为含有凭据。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带 header 或 env 值的条目;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header、env 值或参数、URL(token 可能就在路径里),或带参数的命令行,就视为含有凭据;只有不带参数的 stdio 命令不算。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带凭据的条目;没有该工具的记录时,`managed-mcp-files.json` 列出的文件只要还有 server 也算;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index f2ca60c41..06a330137 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -74,7 +74,7 @@ note cannot be written (another teamai command holds the record), the line stays until a later pull writes it. A Copilot project config's bare top-level servers still count once another tool writes `mcpServers` into the file. On an HTTP-backed team the local agent's `install_mcp` lists a project config before writing a -server with any header, env value, argument, or URL user or query, fails the install when it cannot, and only +server with any header, env value, argument or URL (only a bare stdio command is not), fails the install when it cannot, and only `teamai uninstall` takes that line out; `teamai doctor` checks those files too. ## Invite a member diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 265762d4d..d7be88e32 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -401,6 +401,17 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await check.check()).toBe(true); }); + it('still fails for a file managed-mcp-files.json lists, holding a server, while the manifest has no record for it', async () => { + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + await fse.remove(managedMcpManifestPath(getDataHome(localConfig), projectRoot)); + expect(await trackResolvedMcpFiles(localConfig, [{ tool: 'claude', file: path.join(projectRoot, '.mcp.json') }])).toBe('written'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); + }); + it('has nothing to say of a file whose recorded server carries neither header nor env value', async () => { await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp' } } }); await writeRecord({ name: 'jira', hash: 'h', resolved: false }); diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 29cd00e78..9dabc433b 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -616,8 +616,9 @@ describe('local-agent: MCP install/uninstall commands', () => { } }); - it('adds no line for a server with neither header nor env value', async () => { - const acks = await install(9103, { transport: 'http', url: 'https://clawpro.example.com/mcp' }); + // Any URL counts (a token can sit in its path), so only a bare stdio command carries none. + it('adds no line for a server that carries no credential: a bare stdio command', async () => { + const acks = await install(9103, { transport: 'stdio', command: 'clawpro-mcp' }); expect(acks[0].status).toBe('success'); expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).not.toMatch(/\.mcp\.json/); diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index fbeaa0454..e266541b8 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -365,13 +365,14 @@ describe('a local agent install that carries a credential (#882)', () => { ['an argument', { command: 'npx', args: ['-y', 'server', '--token', 't'] }], ['a URL with a user', { type: 'http', url: 'https://user:t@x.example/mcp' }], ['a URL with a query', { type: 'http', url: 'https://x.example/mcp?key=t' }], + ['a URL with a token in its path', { type: 'http', url: 'https://x.example/mcp/bmcp-t0ken' }], + ['any URL: nothing tells a token in its path from a plain one', { type: 'http', url: 'https://x.example/mcp' }], ['a whole command line', { command: 'server --token t' }], ])('counts %s', (_, entry) => { expect(carriesLocalAgentCredential(entry)).toBe(true); }); it.each([ - ['a plain URL', { type: 'http', url: 'https://x.example/mcp' }], ['a bare command', { command: 'npx' }], ])('does not count %s', (_, entry) => { expect(carriesLocalAgentCredential(entry)).toBe(false); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 91e2b75b0..5e653d7ef 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -601,19 +601,27 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise file.targets); for (const target of [...targets, ...recorded]) { if (holding.has(target.file) || !await pathExists(target.file)) continue; const installed = await installedMcpEntries(target); - const credential = (manifest[managedMcpManifestKey(target.tool, true)] ?? []).some((record) => installed === null + const records = manifest[managedMcpManifestKey(target.tool, true)]; + if (records === undefined && ledger[target.file] !== undefined && (installed === null || installed.size > 0)) { + await hold(target.file); + continue; + } + const credential = (records ?? []).some((record) => installed === null ? record.resolved !== false : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))); if (credential) await hold(target.file); } - return report('MCP headers or env values in plaintext', 'Fix any git error shown, then add each to .git/info/exclude. If git already tracks one, run ' + return report('MCP credentials in plaintext', 'Fix any git error shown, then add each to .git/info/exclude. If git already tracks one, run ' + '`git rm --cached ` and rotate the values it held.'); } for (const target of targets) { diff --git a/src/local-agent.ts b/src/local-agent.ts index d876b579d..74c0ae6b7 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2973,7 +2973,7 @@ async function keepCredentialOutOfGit( const exclusion = await ensureExcludedFromGit(file, { rerun: 'install the MCP server again' }); if (exclusion.kind === 'failed') { throw new Error( - `install_mcp: withheld "${slug}" from ${file}: it carries a header or env value, and teamai could not keep the file ` + `install_mcp: withheld "${slug}" from ${file}: it may carry a credential (a header, env value, argument or URL), and teamai could not keep the file ` + `out of git: ${exclusion.reason}. The file is left as it was. ${exclusion.fix}`, ); } diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 54d974fea..bbdba0432 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -40,9 +40,10 @@ export function carriesResolvedValue( /** * Whether a JSON MCP entry the local agent installs for an HTTP-backed team * carries a credential (#882): a header, env value or argument of any kind, a - * URL with a user or a query, or a command line with arguments in it. Its - * payload holds the values themselves, not `${VAR}` references teamai - * resolves, so nothing tells a token from a plain setting: every one counts. + * URL (a token can sit in its path, as well as in a user or a query), or a + * command line with arguments in it. Its payload holds the values themselves, + * not `${VAR}` references teamai resolves, so nothing tells a token from a + * plain setting: every one counts. Only a bare stdio command does not. */ export function carriesLocalAgentCredential(entry: unknown): boolean { if (typeof entry !== 'object' || entry === null) return false; @@ -52,16 +53,7 @@ export function carriesLocalAgentCredential(entry: unknown): boolean { // OpenCode keeps env under `environment`, and a stdio command with its arguments under `command`. if (['headers', 'env', 'environment', 'args'].some((key) => nonEmpty(fields[key]))) return true; if (Array.isArray(fields.command) ? fields.command.length > 1 : typeof fields.command === 'string' && /\s/.test(fields.command.trim())) return true; - return ['url', 'serverUrl', 'httpUrl'].some((key) => { - const value = fields[key]; - if (typeof value !== 'string') return false; - try { - const url = new URL(value); - return url.username !== '' || url.password !== '' || url.search !== ''; - } catch { - return false; - } - }); + return ['url', 'serverUrl', 'httpUrl'].some((key) => typeof fields[key] === 'string' && fields[key].trim() !== ''); } /** From c071702ff757eada51242539f71cfff204e4e337 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 19:22:00 +0200 Subject: [PATCH 74/85] fix(mcp): read OpenCode's command array, remove only teamai's own bare Copilot copy, hold a shadowed one, and judge a partly lost HTTP record by its entries (#882) --- src/__tests__/doctor-mcp-delivery.test.ts | 14 +++++++++++ src/__tests__/mcp-git-exclude.test.ts | 3 +++ src/__tests__/mcp-reconcile.test.ts | 29 +++++++++++++++++++++++ src/doctor-delivery.ts | 6 ++++- src/local-agent.ts | 12 ++++++---- src/mcp-git-exclude.ts | 4 +++- src/mcp-reconcile.ts | 27 +++++++++++++++++++-- 7 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index d7be88e32..53bb5d483 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -412,6 +412,20 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).toContain(path.join(projectRoot, '.mcp.json')); }); + it('still fails while a server carrying a credential has lost its record, though another server\'s remains', async () => { + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + mcpServers: { + jira: { type: 'http', url: 'https://jira.example/mcp', headers: { Authorization: 'Bearer t0ken' } }, + local: { command: 'local-mcp' }, + }, + }); + await writeRecord({ name: 'local', hash: 'h', resolved: false }); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + }); + it('has nothing to say of a file whose recorded server carries neither header nor env value', async () => { await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp' } } }); await writeRecord({ name: 'jira', hash: 'h', resolved: false }); diff --git a/src/__tests__/mcp-git-exclude.test.ts b/src/__tests__/mcp-git-exclude.test.ts index e266541b8..46bdcc387 100644 --- a/src/__tests__/mcp-git-exclude.test.ts +++ b/src/__tests__/mcp-git-exclude.test.ts @@ -368,12 +368,15 @@ describe('a local agent install that carries a credential (#882)', () => { ['a URL with a token in its path', { type: 'http', url: 'https://x.example/mcp/bmcp-t0ken' }], ['any URL: nothing tells a token in its path from a plain one', { type: 'http', url: 'https://x.example/mcp' }], ['a whole command line', { command: 'server --token t' }], + ['a whole command line in OpenCode\'s one-element array', { type: 'local', command: ['server --token t'] }], + ['a command array with arguments', { type: 'local', command: ['server', '--token', 't'] }], ])('counts %s', (_, entry) => { expect(carriesLocalAgentCredential(entry)).toBe(true); }); it.each([ ['a bare command', { command: 'npx' }], + ['a bare command in a one-element array', { type: 'local', command: ['npx'] }], ])('does not count %s', (_, entry) => { expect(carriesLocalAgentCredential(entry)).toBe(false); }); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 76d5b2b54..4fd9488f3 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1119,6 +1119,35 @@ servers: expect((after.mcpServers as Record)['with-secret']).toEqual(expect.objectContaining({ headers: { Authorization: 'Bearer published-literal' } })); }); + it('never removes a bare server of the member\'s own that shares a name with one teamai writes under mcpServers', async () => { + const mine = { type: 'http', url: 'https://mine.example/mcp' }; + const doc = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { ...doc, jira: mine }); + const jira = ' - name: jira\n transport: http\n url: https://jira.example/mcp\n tools: [copilot]\n'; + await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}${jira}`); + await reconcileMcpForConfig(shared(), projectConfig); + await reconcileMcpForConfig(shared(), projectConfig); + await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); + await reconcileMcpForConfig(shared(), projectConfig); + + expect((await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record).jira).toEqual(mine); + }); + + it('keeps its line while a stale bare copy differs from the entry of that name under mcpServers', async () => { + const stale = (await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record)['with-secret']; + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { + 'with-secret': stale, + mcpServers: { 'with-secret': { type: 'http', url: 'https://example.com/mcp', headers: { Authorization: 'Bearer published-literal' } } }, + }); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + vi.stubEnv('SECRET_TOKEN', ''); + + await reconcileMcpForConfig(shared(), { ...projectConfig, disabledAgents: ['copilot'] } as LocalConfig); + + expect(JSON.stringify(await fse.readJson(path.join(projectRoot, '.mcp.json')))).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('removes Copilot\'s bare entry once the team drops it, leaving the mcpServers Claude wrote', async () => { await writeMcpYaml(open); vi.stubEnv('SECRET_TOKEN', ''); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 5e653d7ef..52141309d 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -616,9 +616,13 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise record.name)); const credential = (records ?? []).some((record) => installed === null ? record.resolved !== false - : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))); + : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))) + // One whose record was lost while another server's remains: judged by the entry itself. + || (records !== undefined && installed !== null && [...installed] + .some(([name, entry]) => !recordedNames.has(name) && carriesLocalAgentCredential(entry))); if (credential) await hold(target.file); } return report('MCP credentials in plaintext', 'Fix any git error shown, then add each to .git/info/exclude. If git already tracks one, run ' diff --git a/src/local-agent.ts b/src/local-agent.ts index 74c0ae6b7..ea80ceb6a 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -42,6 +42,7 @@ import { } from './resources/mcp-format.js'; import { readJsonDoc, + isTeamaiBareCopy, writeJsonDoc, writeCodexAtomic, spliceCodexBlock, @@ -2941,12 +2942,14 @@ async function installMcpServer( if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); } + // The copy a bare install left before another tool added the key would keep the old value beside this one (#882). + // Judged by the record as it was before this install updates it. + const bareCopy = isTeamaiBareCopy(doc, slug, owned); // A credential lands in a project config only once git leaves the file out of a commit, as a pull's does (#882). const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined); await writeJsonAtomic(manifestPath, manifest); - // The copy a bare install left before another tool added the key would keep the old value beside this one (#882). - if (ownedNames.has(slug) && doc.beside?.[slug] !== undefined) delete doc.data[slug]; + if (bareCopy) delete doc.data[slug]; doc.servers[slug] = entry; await writeJsonDoc(targetFile, serverKey, doc); } @@ -3042,9 +3045,10 @@ async function uninstallMcpServer( const allowBare = format === 'copilot' && projectScope; const doc = await readJsonDoc(targetFile, serverKey, allowBare); // Also a bare entry another tool's mcpServers now sits beside (#882). - if (doc && (doc.servers[slug] !== undefined || doc.beside?.[slug] !== undefined)) { + const bareCopy = doc !== null && isTeamaiBareCopy(doc, slug, owned); + if (doc && (doc.servers[slug] !== undefined || bareCopy)) { delete doc.servers[slug]; - if (doc.beside?.[slug] !== undefined) delete doc.data[slug]; + if (bareCopy) delete doc.data[slug]; await writeJsonDoc(targetFile, serverKey, doc); } } diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index bbdba0432..2e64c9868 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -52,7 +52,9 @@ export function carriesLocalAgentCredential(entry: unknown): boolean { Array.isArray(value) ? value.length > 0 : typeof value === 'object' && value !== null && Object.keys(value).length > 0; // OpenCode keeps env under `environment`, and a stdio command with its arguments under `command`. if (['headers', 'env', 'environment', 'args'].some((key) => nonEmpty(fields[key]))) return true; - if (Array.isArray(fields.command) ? fields.command.length > 1 : typeof fields.command === 'string' && /\s/.test(fields.command.trim())) return true; + // A command line in one string, or OpenCode's one-element array holding it, carries its arguments too. + const commandParts: unknown[] = Array.isArray(fields.command) ? fields.command : [fields.command]; + if (commandParts.length > 1 || commandParts.some((part) => typeof part === 'string' && /\s/.test(part.trim()))) return true; return ['url', 'serverUrl', 'httpUrl'].some((key) => typeof fields[key] === 'string' && fields[key].trim() !== ''); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index b16a6cc57..a91763fd7 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -606,6 +606,15 @@ export async function installedMcpEntries( return new Map([...options.underKeyOnly ? [] : Object.entries(doc.beside ?? {}), ...Object.entries(doc.servers)]); } +/** In a Copilot project file that also holds `mcpServers`, a bare server whose value differs from the one of its name there. */ +async function shadowedBareCopilotServer(target: McpTarget): Promise { + if (target.format !== 'copilot' || !target.projectScope) return undefined; + const doc = await readJsonDoc(target.file, MCP_SERVER_KEY.copilot, true).catch(() => null); + if (!doc?.beside) return undefined; + return Object.keys(doc.beside).find((name) => doc.servers[name] !== undefined + && JSON.stringify(doc.servers[name]) !== JSON.stringify(doc.beside?.[name])); +} + /** * Why `target`'s file may hold a value teamai resolved (#882), or null when it * is missing or proven not to. Judged by what is on disk and in the manifest @@ -631,6 +640,10 @@ export async function resolvedValueEvidence( const present = records.map((record) => record.name); const unverified = (ledger.unverified ?? []).find((name) => !installed || installed.has(name)); if (unverified) return `${unverified}, which was in the file when teamai rebuilt its lost record, so teamai cannot tell whether a pull wrote it`; + // A Copilot file's bare server beside a different one of its name under mcpServers: the merged view reads the + // latter, and the bare copy may be one an earlier pull wrote with a value since resolved away (#882). + const shadowed = await shadowedBareCopilotServer(target); + if (shadowed) return `a bare ${shadowed} beside a different ${shadowed} under mcpServers, which may be an earlier pull's`; if (!teamDefs) return present.length > 0 ? `teamai's ${present.join(', ')}, and the team's MCP servers cannot be read` : null; const dropped = present.find((name) => !teamDefs.some((def) => def.name === name)); if (dropped) return `teamai's ${dropped}, which has left the team's MCP servers`; @@ -1558,6 +1571,16 @@ async function forgetUnwrittenMcpConfigs(localConfig: LocalConfig, targets: McpT } } +/** + * Whether the bare Copilot server `name` beside `mcpServers` is the copy a teamai write left before another tool + * added the key (#882): exactly what `owned`'s record says teamai wrote. A member's own server of that name, + * or one edited since, is left alone. + */ +export function isTeamaiBareCopy(doc: { beside?: Record }, name: string, owned: readonly ManagedMcpRecord[]): boolean { + const bare = doc.beside?.[name]; + return bare !== undefined && owned.some((record) => record.name === name && record.hash === entryHash(bare)); +} + // ─── Appliers ──────────────────────────────────────────────── /** Whether it wrote `target`'s file; null when the file does not parse, and so was not read. */ @@ -1594,7 +1617,7 @@ async function applyJson( } nextRecords.push({ name, hash }); // The copy a bare write left before another tool added the key would keep the old value beside this one (#882). - if (ownedNames.has(name) && doc.beside?.[name] !== undefined) { + if (isTeamaiBareCopy(doc, name, owned)) { delete doc.data[name]; dirty = true; } @@ -1611,7 +1634,7 @@ async function applyJson( dirty = true; } // One a bare write left before another tool added the key goes too (#882). - if (doc.beside?.[name] !== undefined) { + if (isTeamaiBareCopy(doc, name, owned)) { delete doc.data[name]; dirty = true; } From 5c13481cc1f704195121ddaaf26de8880381f928 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 19:41:16 +0200 Subject: [PATCH 75/85] fix(mcp): list a project config an older local agent wrote a credential into on the next sync or pull of an HTTP-backed team (#882) --- src/__tests__/local-agent-mcp.test.ts | 23 ++++++++- src/__tests__/mcp-reconcile.test.ts | 33 +++++++++++++ src/doctor-delivery.ts | 29 ++--------- src/local-agent.ts | 29 +++++++++++ src/mcp-reconcile.ts | 69 +++++++++++++++++++++++++-- 5 files changed, 154 insertions(+), 29 deletions(-) diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 9dabc433b..4d25680ee 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -50,6 +50,7 @@ async function setupConfig(bindings: Record = {}): Promise, tool: string = 'codebuddy', + cwd: string = tmpDir, ): Promise>> { // 确保 tool 目录存在,使 isToolInstalled 检查通过 await fse.ensureDir(path.join(tmpDir, `.${tool}`, 'skills')); @@ -67,7 +68,7 @@ async function runResponse( }); vi.stubGlobal('fetch', fetchMock); const { reportAndSyncLocalAgent } = await import('../local-agent.js'); - await reportAndSyncLocalAgent({ cwd: tmpDir, tool, status: 'running' }); + await reportAndSyncLocalAgent({ cwd, tool, status: 'running' }); return acks; } @@ -623,6 +624,26 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(acks[0].status).toBe('success'); expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).not.toMatch(/\.mcp\.json/); }); + + it('lists a config an older install wrote a credential into on the next sync in the workspace, with no command to run', async () => { + await install(9104, bearer); + // As an older local agent left it: no line, no managed-mcp-files.json, no note on the record. + await fse.writeFile(path.join(wsPath, '.git', 'info', 'exclude'), ''); + await fse.remove(await workspaceFile('managed-mcp-files.json')); + const manifestFile = await workspaceFile('managed-mcp.json'); + const manifest = await fse.readJson(manifestFile) as Record>; + manifest['codebuddy:project'] = manifest['codebuddy:project'].map(({ name, hash }) => ({ name, hash })); + await fse.writeJson(manifestFile, manifest); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).not.toBe(''); + + await runResponse({ cmds: [] }, 'codebuddy', wsPath); + + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toMatch(/^\/\.mcp\.json$/m); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).toBe(''); + const sidecar = await fse.readJson(await workspaceFile('managed-mcp-files.json')) as { files: Record }; + expect(Object.entries(sidecar.files)).toEqual([[expect.stringMatching(/\.mcp\.json$/), { tools: ['codebuddy'] }]]); + expect(await fse.readFile(path.join(wsPath, '.mcp.json'), 'utf-8')).toContain('bmcp-test-token'); + }); }); // ─── install_mcp: 缺少 mcp_config 时失败 ────────────────────────── diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 4fd9488f3..48f2ee2c1 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1072,6 +1072,39 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + // No pull writes an HTTP team's servers, but one lists what an older local agent's install_mcp left unlisted. + describe('for an HTTP-backed team, a config an older local agent wrote a credential into', () => { + let httpConfig: LocalConfig; + const file = (): string => path.join(projectRoot, '.mcp.json'); + const written = { mcpServers: { clawpro: { type: 'http', url: 'https://clawpro.example.com/mcp', headers: { Authorization: 'Bearer bmcp-old-token' } } } }; + + beforeEach(async () => { + httpConfig = { ...projectConfig, repo: { ...projectConfig.repo, kind: 'http', url: 'https://teamai.example' } } as LocalConfig; + await fse.writeJson(file(), written); + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + // Without the `resolved` note an install on this version adds. + await fse.outputJson(managedMcpManifestPath(getDataHome(httpConfig), projectRoot), { 'claude:project': [{ name: 'clawpro', hash: 'h' }] }); + }); + + it('lists it in .git/info/exclude on a pull, and records it in managed-mcp-files.json', async () => { + await reconcileMcpForConfig(teamConfig, httpConfig); + + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).toBe(''); + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + expect((await readResolvedMcpFiles(httpConfig)).files).toEqual({ [file()]: { tools: ['claude'] } }); + expect(await fse.readJson(file())).toEqual(written); + }); + + it('writes nothing on a dry run', async () => { + await reconcileMcpForConfig(teamConfig, httpConfig, { dryRun: true }); + + expect(await excludeOf(projectRoot)).not.toMatch(/\.mcp\.json/); + const { resolvedMcpFilesPath } = await import('../mcp-resolved-files.js'); + expect(await fse.pathExists(resolvedMcpFilesPath(httpConfig) ?? '')).toBe(false); + }); + }); + describe('a bare Copilot config another tool then writes mcpServers into (Copilot and Claude on .mcp.json)', () => { const shared = (): TeamaiConfig => ({ ...teamConfig, diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 52141309d..b07d712cf 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -561,7 +561,7 @@ export async function buildMcpGitExcludeCheck(ctx: DoctorContext): Promise file.targets); - for (const target of [...targets, ...recorded]) { - if (holding.has(target.file) || !await pathExists(target.file)) continue; - const installed = await installedMcpEntries(target); - const records = manifest[managedMcpManifestKey(target.tool, true)]; - if (records === undefined && ledger[target.file] !== undefined && (installed === null || installed.size > 0)) { - await hold(target.file); - continue; - } - const recordedNames = new Set((records ?? []).map((record) => record.name)); - const credential = (records ?? []).some((record) => installed === null - ? record.resolved !== false - : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))) - // One whose record was lost while another server's remains: judged by the entry itself. - || (records !== undefined && installed !== null && [...installed] - .some(([name, entry]) => !recordedNames.has(name) && carriesLocalAgentCredential(entry))); - if (credential) await hold(target.file); + // No mcp.yaml to judge by: the files that may hold a credential the local agent wrote. + for (const { file } of await localAgentCredentialFiles(localConfig, targets)) { + if (!holding.has(file)) await hold(file); } return report('MCP credentials in plaintext', 'Fix any git error shown, then add each to .git/info/exclude. If git already tracks one, run ' + '`git rm --cached ` and rotate the values it held.'); diff --git a/src/local-agent.ts b/src/local-agent.ts index ea80ceb6a..9b4d07fd3 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -3214,6 +3214,8 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi const tag = localAgentTag(context); log.debug(`${tag} run: endpoint=${config.endpoint}`); + // Set when the server asked to uninstall teamai: its cleanup is not undone afterwards. + let uninstalling = false; // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is // tied to the report path and must stay skipped inside the CloudStudio sandbox, @@ -3270,6 +3272,7 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); if (commands.length > 0) { log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); + uninstalling = commands.some((c) => c.type === 'uninstall_teamai'); const modelConfigApplied = await processCommands(config, commands, context); if (modelConfigApplied && !skipReport) { const reportPayload = await buildReportPayload(config, context); @@ -3286,10 +3289,36 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi log.error(`${tag} sync FAILED: ${error}`); await appendErrorLog({ error, context }); } + // Also when the sync failed: what an install wrote is on disk either way. + if (!uninstalling) await protectWorkspaceMcpConfigs(config, context.cwd); return true; } +/** + * List in `.git/info/exclude` each MCP config of the current workspace that + * may hold a credential an install wrote (#882). An older local agent wrote + * one without listing it, and the server sends no install again for a server + * already in place. The workspace and its files resolve as `install_mcp` + * resolves them. + */ +async function protectWorkspaceMcpConfigs(config: LocalAgentConfig, cwd?: string): Promise { + const workspacePath = await resolveWorkspacePath(cwd); + if (!workspacePath) return; + try { + const { resolveDataHomeForScope } = await import('./config.js'); + const dataHome = await resolveDataHomeForScope('project', workspacePath); + const localConfig = await createResourceLocalConfig(config, 'project', getUserHome(), workspacePath); + const { protectLocalAgentMcpConfigs } = await import('./mcp-reconcile.js'); + await protectLocalAgentMcpConfigs(createLocalAgentTeamConfig(config.endpoint), { ...localConfig, dataHome }); + } catch (e) { + log.warn( + `Could not check ${workspacePath}'s MCP configs for a credential to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` + + 'The next session checks again; do not commit them meanwhile.', + ); + } +} + function statusFromEvent(event?: DashboardEvent): string { if (!event) return 'running'; if (event.type === 'stop' || event.type === 'process_exit') return 'stopped'; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index a91763fd7..00cc7a694 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -48,6 +48,7 @@ import { log } from './utils/logger.js'; import { loadProjectMcpManifest } from './utils/mcp-manifest.js'; import { isOnPath, SAFE_BIN_RE, type LookPathOptions } from './utils/lookpath.js'; import { + carriesLocalAgentCredential, carriesResolvedValue, ensureExcludedFromGit, excludeFromGit, @@ -1140,7 +1141,9 @@ export async function reconcileMcpForConfig( * `.git/info/exclude` (#882), and take out the line of one proven clean. It * covers what is on disk, whether or not this run delivered to it: the file of * a disabled or undetected tool, or one written before the team turned - * delivery off, still holds what a pull wrote. + * delivery off, still holds what a pull wrote. For an HTTP-backed team, whose + * servers no pull writes, each config that may hold a credential its local + * agent wrote (`protectLocalAgentMcpConfigs`). */ async function protectResolvedMcpConfigs( teamConfig: TeamaiConfig, @@ -1150,9 +1153,11 @@ async function protectResolvedMcpConfigs( before: ManagedMcpManifest | undefined, ): Promise { const { projectRoot } = localConfig; - if (localConfig.scope !== 'project' || !projectRoot || localConfig.repo.kind === 'http') return; + if (localConfig.scope !== 'project' || !projectRoot) return; try { - await protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions, written, before); + await (localConfig.repo.kind === 'http' + ? protectLocalAgentMcpConfigs(teamConfig, localConfig) + : protectProjectMcpConfigs(teamConfig, localConfig, projectRoot, exclusions, written, before)); } catch (e) { log.warn( `Could not check this project's MCP configs for resolved values to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` @@ -1161,6 +1166,64 @@ async function protectResolvedMcpConfigs( } } +/** + * The targets among `targets`, and those managed-mcp-files.json recorded under + * a mapping another teamai.yaml made, whose project MCP config may hold a + * credential an HTTP-backed team's local agent wrote (#882). No mcp.yaml to + * judge by: a server its install recorded as carrying a credential, or an + * older install's entry carrying one (a header, env value, argument or URL), + * or one whose record was lost while another server's remains. With no record + * of the tool at all, a file managed-mcp-files.json lists holds while it holds + * any server, or doesn't parse: nothing says which of them the local agent + * wrote. A file two tools map may appear once for each. Read-only. + */ +export async function localAgentCredentialFiles(localConfig: LocalConfig, targets: McpTarget[]): Promise { + const { projectRoot } = localConfig; + if (localConfig.scope !== 'project' || !projectRoot) return []; + const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const ledger = (await readResolvedMcpFiles(localConfig)).files; + const recorded = [...(await recordedMcpTargets(localConfig, targets)).values()].flatMap((file) => file.targets); + const held: McpTarget[] = []; + for (const target of [...targets, ...recorded]) { + if (!await pathExists(target.file)) continue; + const installed = await installedMcpEntries(target); + const records = manifest[managedMcpManifestKey(target.tool, true)]; + if (records === undefined) { + if (ledger[target.file] !== undefined && (installed === null || installed.size > 0)) held.push(target); + continue; + } + const recordedNames = new Set(records.map((record) => record.name)); + const credential = records.some((record) => installed === null + ? record.resolved !== false + : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))) + || (installed !== null && [...installed].some(([name, entry]) => !recordedNames.has(name) && carriesLocalAgentCredential(entry))); + if (credential) held.push(target); + } + return held; +} + +/** + * For an HTTP-backed team: list in `.git/info/exclude` each project MCP + * config that may hold a credential its local agent wrote + * (`localAgentCredentialFiles`), and record it in managed-mcp-files.json + * (#882). An older local agent wrote one without listing it, and no install + * runs again for a server already in place. Only `teamai uninstall` takes + * such a line out. The caller skips a dry run. + */ +export async function protectLocalAgentMcpConfigs(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const mapped = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); + const unmapped = await unmappedMcpDefaults(mapped); + const held = await localAgentCredentialFiles(localConfig, mapped.filter((target) => !unmapped.has(target))); + if (held.length === 0) return; + for (const file of new Set(held.map((target) => target.file))) await excludeFromGit(file); + // A failure does not undo the line: the exclusion protects the file. + const result = await trackResolvedMcpFiles(localConfig, held.map(({ tool, file }) => ({ tool, file }))) + .catch((e: unknown) => e instanceof Error ? e.message : String(e)); + if (result !== 'written' && result !== 'unchanged') { + log.debug(`Did not record ${held.map((target) => target.file).join(', ')} in managed-mcp-files.json: ${result === 'locked' ? 'another teamai command held it past the wait' : result}.`); + } +} + async function protectProjectMcpConfigs( teamConfig: TeamaiConfig, localConfig: LocalConfig, From aa7ff2de2911b81e0b5dfc0c73b0be7adaac96db Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 19:41:16 +0200 Subject: [PATCH 76/85] fix(mcp): document that the local agent's sync and pull list an older install's credential file (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 91b8611ca..4c0244b98 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1166,7 +1166,7 @@ Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, re 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. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header, env value or argument, a URL (a token can sit in its path), or a command line with arguments counts as holding a credential; only a bare stdio command doesn't. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry carrying one, and, with no record of the tool, a file `managed-mcp-files.json` lists while it holds any server; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.github/mcp.json`, `.cursor/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret. Whenever such a file would hold a value teamai resolved and git would track it, teamai lists the path in the clone's `.git/info/exclude`, inside a `# [teamai:mcp-exclude:start]` block (the worktrees of a repo share it), before it writes the value. A config reached through a symlinked directory (say `.cursor/` linking to `config/`) is judged where the write lands: that path (`/config/mcp.json`) is the one listed, checked and reported, and a tracked one is named with both paths. A symlink at the file itself is replaced by the write, so there the file's own path counts. That covers a file this pull did not write: one written earlier for a tool since disabled, one at the built-in location of a tool the team has dropped from `toolPaths` or moved elsewhere (it counts while it holds any MCP server, since teamai's record for that tool describes another file or none; one another tool maps today, such as CodeBuddy's `.mcp.json`, which Claude maps, while it holds a server that tool did not write, as below), one written under a `toolPaths` mapping the team has since changed (each worktree records the files it wrote a resolved value to in `managed-mcp-files.json`, beside its `managed-mcp.json`; for one an older teamai wrote before it kept that record, the first pull reads each `mcpProject` path in the team repo's history of `teamai.yaml`, and the built-in ones teamai has since changed (CodeBuddy's `.codebuddy/mcp.json`), once, as far as the clone has it, inside the project only, skipping a path the same tool maps today; such a file counts while it holds any MCP server, since teamai's record for the tool describes only today's path (one another tool maps today, while it holds a server that tool did not write, as below), and `teamai doctor` checks the same files until that pull; one git tracks is not listed, since a line does nothing for it, but is recorded as tracked whatever it holds, judged as the others once git no longer tracks it (`git rm --cached`), and forgotten once it is gone from both the disk and git), or one still holding a server since removed from `mcp.yaml`. An entry a pull wrote with a resolved value counts while it is unchanged, even after the team makes its `${VAR}` a literal. While the worktree has no `managed-mcp.json` at all (lost, or before its first pull), a config git does not track counts while it holds a server no record claims, one of your own included: the pull notes those servers in `managed-mcp-files.json`, as when it rebuilds a lost record, and they keep its path until they leave the file; `teamai doctor` checks the same way. So does a config a pull writes a tool's first record for while `managed-mcp.json` holds none for that tool (lost, or teamai's first delivery to it). A path git cannot say it ignores is listed all the same once `git ls-files` shows the file untracked; when git cannot say that either, it counts as git failing. When it cannot — `.git/info` or the exclude file is not writable, another teamai command holds the exclude file past a short wait, git already tracks the file, a rule in your own git ignore files re-includes it (say `!/.mcp.json`; the warning names it), or git fails — it leaves that file as it was (an entry an earlier pull wrote stays), warns with the reason and the fix, and `teamai mcp list` and `teamai doctor` report the server as withheld from each tool a pull would write it to; make the file writable (or `git rm --cached` the tracked file, or remove the rule that re-includes it) and run `teamai pull` again. A tracked file is reported first, and listed nowhere. The committed `.gitignore` is left alone, a path git already ignores adds nothing, and a pull, `teamai mcp remove` and `teamai uninstall` remove a path from the block (the block with its last path) once that file is gone, holds no MCP server, or holds none of: a team server with a resolved value, an entry of teamai's that cleanup left, a server that was in the file when teamai rebuilt a lost `managed-mcp.json`, or the value (8+ characters) of a variable still set in the environment, with teamai's record of what it wrote there (`managed-mcp.json`) present before the command ran, readable, and holding an entry for that file's tool (for a file two tools map, such as Claude and CodeBuddy on `.mcp.json`: for each tool `managed-mcp-files.json` says wrote a resolved value there, or for each tool mapping it when it names none; an empty, unreadable or truncated record proves nothing, and neither does one written by a pull that rebuilt it or found no record for its tool in `managed-mcp.json`, while that pull could not note the file's other servers in `managed-mcp-files.json`, until a later pull notes them). A file written under a mapping since changed, one at the built-in location of a tool the team dropped or moved (unless another tool maps it today), or one in a linked worktree of a nested repository, needs to be gone or hold no MCP server. One written for a tool the team has since moved elsewhere (recorded, found in that history, or at the tool's built-in location), that another tool's mapping still reaches, also keeps its path while it holds a server the tools now mapping it did not write (by their `managed-mcp.json` record); as in any file under a changed mapping, a server of your own there keeps it too. `teamai uninstall` applies that to the file in every worktree of the repository; a pull and `teamai mcp remove` apply it only to the current worktree's file, and keep the path while the file in any other worktree still holds an MCP server: an entry that worktree's last pull wrote (say, a `${VAR}` the team has since made a literal) is judged only by a pull there. A path a pull listed and then wrote no value into (the file does not parse, or holds a server of your own under the team's name) comes out again at the end of that pull, and so does its record in `managed-mcp-files.json`. Otherwise, or for a file it cannot check (for example one that does not parse), the path stays, and `teamai uninstall` warns, naming the file and why: remove teamai's servers from it, then delete that line yourself (with its last line, the block's markers). `teamai doctor` reports such a file git would still commit or cannot answer for — for example one already tracked: `git rm --cached` it and rotate the token. A Copilot project config whose servers sit bare at the top level has those counted, and teamai's among them removed once the team drops them, after another tool writes `mcpServers` into the same file too. For an HTTP-backed team (`teamai init --http`) no pull writes a server: the local agent's `install_mcp` does, with the values themselves rather than `${VAR}` references, so a project-scope server carrying any header, env value or argument, a URL (a token can sit in its path), or a command line with arguments counts as holding a credential; only a bare stdio command doesn't. Its install lists the file first and records it in `managed-mcp-files.json`, and when it cannot (the same causes as above), writes nothing and reports the install as failed with the reason. A file an older local agent wrote a credential into without listing it is listed and recorded, judged as `teamai doctor` judges it below, by the local agent's next sync in that workspace (its hooks run one in each session) and by a `teamai pull` there; a dry run writes nothing. No command but `teamai uninstall` takes such a line out. `teamai doctor` checks those files by the local agent's records: one it noted as carrying a credential, or an older install's entry carrying one, and, with no record of the tool, a file `managed-mcp-files.json` lists while it holds any server; add a file it names to `.git/info/exclude` yourself, or `git rm --cached` it and rotate the token. 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 609e44c56..db5e87cb4 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1068,7 +1068,7 @@ Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header、env 值或参数、URL(token 可能就在路径里),或带参数的命令行,就视为含有凭据;只有不带参数的 stdio 命令不算。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带凭据的条目;没有该工具的记录时,`managed-mcp-files.json` 列出的文件只要还有 server 也算;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.github/mcp.json`、`.cursor/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥。只要这类文件将含有 teamai 解析出的值且 git 会跟踪它,teamai 就会在写入该值之前把路径写入本地克隆的 `.git/info/exclude`,放在 `# [teamai:mcp-exclude:start]` 块中(同一仓库的各 worktree 共用该文件)。经由符号链接目录访问的配置(例如 `.cursor/` 指向 `config/`)按写入实际落到的位置判断:写入 exclude、检查和报告的都是该路径(`/config/mcp.json`),已被跟踪时会同时给出两个路径。文件本身是符号链接时,写入会替换该链接,因此以文件自身的路径为准。本次 pull 未写入的文件同样适用:之前为某个现已禁用的工具写入的文件,团队已从 `toolPaths` 移除或改到别处的工具的内置位置上的文件(只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录描述的是另一个文件或没有文件;当前由另一个工具映射的文件,例如 Claude 映射的 CodeBuddy 的 `.mcp.json`,则在含有该工具未写入的 server 时算数,见下文),在团队此后改动的 `toolPaths` 映射下写入的文件(每个 worktree 会把写入过解析值的文件记录在其 `managed-mcp.json` 旁的 `managed-mcp-files.json` 中;对于旧版 teamai 在有这份记录之前写入的文件,第一次 pull 会读取一次团队仓库中 `teamai.yaml` 历史里的每个 `mcpProject` 路径,以及 teamai 此后改掉的内置路径(CodeBuddy 的 `.codebuddy/mcp.json`),以克隆中现有的历史为限,且只看项目内的文件,跳过同一工具当前仍映射的路径;这类文件只要含有任何 MCP server 就算数,因为 teamai 对该工具的记录只描述当前路径(当前由另一个工具映射的文件,则在含有该工具未写入的 server 时算数,见下文),在那次 pull 之前 `teamai doctor` 也会检查这些文件;被 git 跟踪的文件不会写入 exclude(写入也不起作用),但无论其内容如何都会记为已跟踪,待 git 不再跟踪它(`git rm --cached`)后按其他此类文件的规则判断,直到它从磁盘和 git 中都消失才会被遗忘),或仍含已从 `mcp.yaml` 删除的 server 的文件。pull 写入的带解析值的条目只要未被改动就一直算数,即使团队后来把其中的 `${VAR}` 改成了字面值。worktree 中完全没有 `managed-mcp.json` 时(记录丢失,或在其第一次 pull 之前),未被 git 跟踪的配置只要含有任何记录都未认领的 server 就算数,你自己的 server 也包括在内:pull 会像重建丢失的记录时那样把这些 server 记入 `managed-mcp-files.json`,在它们离开该文件之前该路径一直保留;`teamai doctor` 也按同样方式检查。`managed-mcp.json` 中没有某个工具的记录时(记录丢失,或这是 teamai 对该工具的第一次投递),pull 为该工具写入第一份记录的配置也按此处理。git 无法判断是否忽略的路径,只要 `git ls-files` 显示该文件未被跟踪,也会照样写入;若连这一点也无法判断,则按 git 出错处理。若无法写入——`.git/info` 或 exclude 文件不可写、另一个 teamai 命令在短暂等待后仍占用 exclude 文件、git 已跟踪该文件、你自己的 git 忽略文件中有规则重新包含了它(例如 `!/.mcp.json`;警告会指出该规则),或 git 出错——teamai 会保持该文件原样(之前 pull 写入的条目保留),给出原因与修复方法的警告,`teamai mcp list` 和 `teamai doctor` 也会针对 pull 会写入它的每个工具,把该 server 报告为未写入(withheld);请让文件可写(或对已跟踪的文件执行 `git rm --cached`,或删除重新包含它的规则),再运行 `teamai pull`。已被跟踪的文件会优先报告,且不会写入任何路径。不会改动已提交的 `.gitignore`,git 已忽略的路径不会重复添加,pull、`teamai mcp remove` 和 `teamai uninstall` 会从块中移除某个路径(移除最后一个路径时连同整个块),前提是该文件已不存在、不含任何 MCP server,或在命令运行前 teamai 的写入记录(`managed-mcp.json`)就已存在、可以解析且记有该文件所属工具的条目的情况下(对于两个工具共用的文件,例如 Claude 和 CodeBuddy 共用的 `.mcp.json`:需记有 `managed-mcp-files.json` 中写入过解析值的每个工具的条目;若其中没有列出任何工具,则需记有映射到它的每个工具的条目;空的、无法读取或被截断的记录不能作为依据;pull 重建记录时、或在 `managed-mcp.json` 中没有该工具的记录时写入记录时,若无法把文件中的其他 server 记入 `managed-mcp-files.json`,该记录在之后某次 pull 记下它们之前也不能作为依据)不含以下任何一项:带解析值的团队 server、清理后仍残留的 teamai 条目、teamai 重建丢失的 `managed-mcp.json` 时文件中已有的 server、仍在环境中设置的变量的值(8 个字符以上)。在已改动的映射下写入的文件、团队已移除或改到别处的工具的内置位置上的文件(当前有另一个工具映射到它的除外),或位于嵌套仓库某个关联 worktree 中的文件,须已不存在或不含任何 MCP server。为团队此后改到别处的工具写入(有记录、在上述历史中找到,或位于该工具的内置位置)、但仍被另一个工具的映射指向的文件,只要含有当前映射到它的工具未写入的 server(以它们的 `managed-mcp.json` 记录为准),也会保留该路径;与其他在已改动映射下写入的文件一样,你自己的 server 也会让它保留。`teamai uninstall` 对仓库每个 worktree 中的该文件都按此判断;pull 和 `teamai mcp remove` 只对当前 worktree 的文件按此判断,只要其他任一 worktree 中的该文件仍含 MCP server,就保留该路径:那个 worktree 上次 pull 写入的条目(例如团队后来改成字面值的 `${VAR}`)只能由在那里运行的 pull 判断。某次 pull 写入了路径、随后却没有把值写进该文件(文件无法解析,或其中有你自己的同名 server)时,该路径会在这次 pull 结束时移除,它在 `managed-mcp-files.json` 中的记录也会一并移除。否则,或对无法检查的文件(例如无法解析),会保留该路径,`teamai uninstall` 会给出警告,说明文件及原因:请先从中移除 teamai 的 server,再自行删除那一行(删到最后一行时连同块的首尾标记)。`teamai doctor` 会报告 git 仍会提交或无法判断的这类文件——例如已被跟踪的文件:请 `git rm --cached` 并轮换 token。Copilot 项目级配置中直接写在顶层(bare)的 server,在另一个工具也向同一文件写入 `mcpServers` 之后同样算数;其中属于 teamai 的,在团队删除它们后会被移除。对于 HTTP 模式的团队(`teamai init --http`),server 不由 pull 写入,而由本地 agent 的 `install_mcp` 写入,写入的是值本身而非 `${VAR}` 引用,因此项目级 server 只要带有任何 header、env 值或参数、URL(token 可能就在路径里),或带参数的命令行,就视为含有凭据;只有不带参数的 stdio 命令不算。安装时会先把该文件写入 exclude 并记入 `managed-mcp-files.json`;若无法写入(原因同上),则不写入任何内容,并把本次安装报告为失败,附上原因。旧版本地 agent 写入了凭据却未写入 exclude 的文件,会由本地 agent 在该工作区的下一次同步(其 hook 在每个会话中都会运行一次)以及在那里运行的 `teamai pull` 写入 exclude 并记入 `managed-mcp-files.json`,判断方式与下文 `teamai doctor` 相同;dry run 不写入任何内容。除 `teamai uninstall` 外,没有命令会移除这样的路径。`teamai doctor` 按本地 agent 的记录检查这些文件:记录为带有凭据的 server,或旧版安装写入的带凭据的条目;没有该工具的记录时,`managed-mcp-files.json` 列出的文件只要还有 server 也算;对它指出的文件,请自行把路径加入 `.git/info/exclude`,或 `git rm --cached` 并轮换 token。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 06a330137..8b6186653 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -75,7 +75,8 @@ until a later pull writes it. A Copilot project config's bare top-level servers still count once another tool writes `mcpServers` into the file. On an HTTP-backed team the local agent's `install_mcp` lists a project config before writing a server with any header, env value, argument or URL (only a bare stdio command is not), fails the install when it cannot, and only -`teamai uninstall` takes that line out; `teamai doctor` checks those files too. +`teamai uninstall` takes that line out. The next sync or `teamai pull` in the +workspace also lists a file an older local agent wrote a credential into; `teamai doctor` checks those files too. ## Invite a member From de6ae4c6a54378051ec58689f8bcab7fa6f89dd3 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 19:44:35 +0200 Subject: [PATCH 77/85] fix(mcp): have the local agent's sync say a new session tries again and call the file's content a credential (#882) --- src/__tests__/local-agent-mcp.test.ts | 20 ++++++++++++++++++++ src/local-agent.ts | 3 ++- src/mcp-git-exclude.ts | 6 +++--- src/mcp-reconcile.ts | 8 ++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 4d25680ee..cd205c372 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -625,6 +625,26 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).not.toMatch(/\.mcp\.json/); }); + it('says the next session tries again when that sync cannot list the file, and calls it a credential', async () => { + await install(9105, bearer); + const excludeFile = path.join(wsPath, '.git', 'info', 'exclude'); + await fse.writeFile(excludeFile, ''); + await fse.remove(await workspaceFile('managed-mcp-files.json')); + await fse.chmod(excludeFile, 0o444); + const { log } = await import('../utils/logger.js'); + vi.mocked(log.warn).mockClear(); + try { + await runResponse({ cmds: [] }, 'codebuddy', wsPath); + } finally { + await fse.chmod(excludeFile, 0o644); + } + + const warned = vi.mocked(log.warn).mock.calls.map(([line]) => String(line)).join('\n'); + expect(warned).toContain('may hold a credential'); + expect(warned).toContain('start a new session'); + expect(warned).not.toContain('teamai pull'); + }); + it('lists a config an older install wrote a credential into on the next sync in the workspace, with no command to run', async () => { await install(9104, bearer); // As an older local agent left it: no line, no managed-mcp-files.json, no note on the record. diff --git a/src/local-agent.ts b/src/local-agent.ts index 9b4d07fd3..a208601e4 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -3310,7 +3310,8 @@ async function protectWorkspaceMcpConfigs(config: LocalAgentConfig, cwd?: string const dataHome = await resolveDataHomeForScope('project', workspacePath); const localConfig = await createResourceLocalConfig(config, 'project', getUserHome(), workspacePath); const { protectLocalAgentMcpConfigs } = await import('./mcp-reconcile.js'); - await protectLocalAgentMcpConfigs(createLocalAgentTeamConfig(config.endpoint), { ...localConfig, dataHome }); + // The sync at the next session start checks again, not a pull. + await protectLocalAgentMcpConfigs(createLocalAgentTeamConfig(config.endpoint), { ...localConfig, dataHome }, { rerun: 'start a new session' }); } catch (e) { log.warn( `Could not check ${workspacePath}'s MCP configs for a credential to keep out of git: ${e instanceof Error ? e.message : String(e)}. ` diff --git a/src/mcp-git-exclude.ts b/src/mcp-git-exclude.ts index 2e64c9868..5b469be4e 100644 --- a/src/mcp-git-exclude.ts +++ b/src/mcp-git-exclude.ts @@ -319,12 +319,12 @@ async function reincludingRule(file: string): Promise<{ source: string; line: st * `ensureExcludedFromGit` for a file already on disk that may hold a resolved * value, warning when it fails rather than failing the sync that wrote the file. */ -export async function excludeFromGit(file: string): Promise { +export async function excludeFromGit(file: string, options: { rerun?: string; holds?: string } = {}): Promise { if (!await pathExists(file)) return; - const exclusion = await ensureExcludedFromGit(file); + const exclusion = await ensureExcludedFromGit(file, { rerun: options.rerun }); if (exclusion.kind === 'failed') { log.warn( - `${file} may hold a resolved MCP variable, and teamai could not keep it out of git: ${exclusion.reason}. ` + `${file} may hold ${options.holds ?? 'a resolved MCP variable'}, and teamai could not keep it out of git: ${exclusion.reason}. ` + `${exclusion.fix} Do not commit the file meanwhile.`, ); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 00cc7a694..508d605c3 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1210,12 +1210,16 @@ export async function localAgentCredentialFiles(localConfig: LocalConfig, target * runs again for a server already in place. Only `teamai uninstall` takes * such a line out. The caller skips a dry run. */ -export async function protectLocalAgentMcpConfigs(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { +export async function protectLocalAgentMcpConfigs( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + options: { rerun?: string } = {}, +): Promise { const mapped = await resolveMcpTargets(teamConfig, localConfig, { includeUndetected: true }); const unmapped = await unmappedMcpDefaults(mapped); const held = await localAgentCredentialFiles(localConfig, mapped.filter((target) => !unmapped.has(target))); if (held.length === 0) return; - for (const file of new Set(held.map((target) => target.file))) await excludeFromGit(file); + for (const file of new Set(held.map((target) => target.file))) await excludeFromGit(file, { rerun: options.rerun, holds: 'a credential' }); // A failure does not undo the line: the exclusion protects the file. const result = await trackResolvedMcpFiles(localConfig, held.map(({ tool, file }) => ({ tool, file }))) .catch((e: unknown) => e instanceof Error ? e.message : String(e)); From d19c516862bffa32849c9cbac034ddc77d6bcba7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 19:56:55 +0200 Subject: [PATCH 78/85] fix(mcp): run the local agent's sync protection after an uninstall_teamai too: a failed or partial one leaves what to keep out of git (#882) --- src/__tests__/local-agent-mcp.test.ts | 11 +++++++++++ src/local-agent.ts | 9 ++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index cd205c372..a61c57f6e 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -625,6 +625,17 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).not.toMatch(/\.mcp\.json/); }); + it('still lists that config when the sync also carried an uninstall_teamai that failed', async () => { + await install(9106, bearer); + await fse.writeFile(path.join(wsPath, '.git', 'info', 'exclude'), ''); + await fse.remove(await workspaceFile('managed-mcp-files.json')); + vi.stubEnv('TEAMAI_DISABLE_REMOTE_CMD', '1'); + + await runResponse({ cmds: [{ id: 9107, type: 'uninstall_teamai', cmd: 'teamai uninstall --force --agent codebuddy' }] }, 'codebuddy', wsPath); + + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toMatch(/^\/\.mcp\.json$/m); + }); + it('says the next session tries again when that sync cannot list the file, and calls it a credential', async () => { await install(9105, bearer); const excludeFile = path.join(wsPath, '.git', 'info', 'exclude'); diff --git a/src/local-agent.ts b/src/local-agent.ts index a208601e4..99d7e238a 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -3214,8 +3214,6 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi const tag = localAgentTag(context); log.debug(`${tag} run: endpoint=${config.endpoint}`); - // Set when the server asked to uninstall teamai: its cleanup is not undone afterwards. - let uninstalling = false; // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is // tied to the report path and must stay skipped inside the CloudStudio sandbox, @@ -3272,7 +3270,6 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); if (commands.length > 0) { log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); - uninstalling = commands.some((c) => c.type === 'uninstall_teamai'); const modelConfigApplied = await processCommands(config, commands, context); if (modelConfigApplied && !skipReport) { const reportPayload = await buildReportPayload(config, context); @@ -3289,8 +3286,10 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi log.error(`${tag} sync FAILED: ${error}`); await appendErrorLog({ error, context }); } - // Also when the sync failed: what an install wrote is on disk either way. - if (!uninstalling) await protectWorkspaceMcpConfigs(config, context.cwd); + // Also when the sync failed: what an install wrote is on disk either way. Also after an uninstall_teamai: + // one that removed teamai's servers and records leaves nothing to list, and one that failed or kept the + // shared files (another agent remains) leaves what still needs keeping out of git. + await protectWorkspaceMcpConfigs(config, context.cwd); return true; } From 8b8d9fcca5b6518323785dced43578cd7af3c593 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 09:44:16 +0200 Subject: [PATCH 79/85] fix(mcp): judge a local-agent entry a failed write left by what it holds, not by the new install's resolved: false (#882) install_mcp records the new entry before writing the config. When that write fails, the older entry, credential included, stays in the file while the record says resolved: false. localAgentCredentialFiles now trusts resolved: false only while the entry on disk has the recorded hash; otherwise it checks the entry itself. The doctor fixture that used a placeholder hash now records the hash an install writes. --- src/__tests__/doctor-mcp-delivery.test.ts | 6 ++++-- src/__tests__/local-agent-mcp.test.ts | 26 +++++++++++++++++++++++ src/mcp-reconcile.ts | 11 +++++++--- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 53bb5d483..1348609c8 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -427,8 +427,10 @@ describe('doctor — MCP servers delivered on disk', () => { }); it('has nothing to say of a file whose recorded server carries neither header nor env value', async () => { - await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira: { type: 'http', url: 'https://jira.example/mcp' } } }); - await writeRecord({ name: 'jira', hash: 'h', resolved: false }); + const jira = { type: 'http', url: 'https://jira.example/mcp' }; + await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira } }); + const { entryHash } = await import('../resources/mcp-format.js'); + await writeRecord({ name: 'jira', hash: entryHash(jira), resolved: false }); expect(await excludeCheck()).toBeUndefined(); }); diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index a61c57f6e..8260310cc 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -675,6 +675,32 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(Object.entries(sidecar.files)).toEqual([[expect.stringMatching(/\.mcp\.json$/), { tools: ['codebuddy'] }]]); expect(await fse.readFile(path.join(wsPath, '.mcp.json'), 'utf-8')).toContain('bmcp-test-token'); }); + + it('still lists that config when an install replacing its entry with a bare command cannot write the file', async () => { + await install(9105, bearer); + await fse.writeFile(path.join(wsPath, '.git', 'info', 'exclude'), ''); + await fse.remove(await workspaceFile('managed-mcp-files.json')); + const manifestFile = await workspaceFile('managed-mcp.json'); + const manifest = await fse.readJson(manifestFile) as Record>; + manifest['codebuddy:project'] = manifest['codebuddy:project'].map(({ name, hash }) => ({ name, hash })); + await fse.writeJson(manifestFile, manifest); + // The config's directory refuses the write: the old entry, and its token, stay. + await fse.chmod(wsPath, 0o555); + let acks; + try { + acks = await install(9106, { transport: 'stdio', command: 'clawpro-mcp' }); + } finally { + await fse.chmod(wsPath, 0o755); + } + + expect(acks[0].status).toBe('failed'); + expect(await fse.readFile(path.join(wsPath, '.mcp.json'), 'utf-8')).toContain('bmcp-test-token'); + + await runResponse({ cmds: [] }, 'codebuddy', wsPath); + + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toMatch(/^\/\.mcp\.json$/m); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).toBe(''); + }); }); // ─── install_mcp: 缺少 mcp_config 时失败 ────────────────────────── diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 292e61988..64bdc72f6 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1293,9 +1293,14 @@ export async function localAgentCredentialFiles(localConfig: LocalConfig, target continue; } const recordedNames = new Set(records.map((record) => record.name)); - const credential = records.some((record) => installed === null - ? record.resolved !== false - : installed.has(record.name) && (record.resolved ?? carriesLocalAgentCredential(installed.get(record.name)))) + const credential = records.some((record) => { + if (installed === null) return record.resolved !== false; + if (!installed.has(record.name)) return false; + const entry = installed.get(record.name); + // `resolved: false` speaks for the entry its install wrote: an older one a failed write left is judged by what it holds. + const noted = record.resolved === true || entryHash(entry) === record.hash ? record.resolved : undefined; + return noted ?? carriesLocalAgentCredential(entry); + }) || (installed !== null && [...installed].some(([name, entry]) => !recordedNames.has(name) && carriesLocalAgentCredential(entry))); if (credential) held.push(target); } From 17b1a59d7fccdf7108d6806d476c3bd2c782683a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 09:44:16 +0200 Subject: [PATCH 80/85] fix(mcp): keep a moved Copilot's stale bare server apart from a name another tool owns under mcpServers (#882) recordedMcpFileEvidence merged a Copilot project file's bare servers with those under mcpServers by name, so Claude owning a nested jira read as owning a bare jira Copilot wrote with a token before it moved. Bare servers now count as owned only by a Copilot record: no other tool writes there. --- src/__tests__/doctor-mcp-delivery.test.ts | 28 ++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 23 +++++++++++++++ src/mcp-reconcile.ts | 35 ++++++++++++++++++----- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 1348609c8..81ac2b1f3 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -368,6 +368,34 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix ?? '').toContain(shared); }); + it('fails for a moved Copilot\'s file while the tool mapping it today owns that server name only under mcpServers', async () => { + const { trackResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + teamConfig.toolPaths = { + ...teamConfig.toolPaths, + cursor: { skills: '.cursor/skills', mcp: '.cursor/mcp.json', mcpProject: 'shared/mcp.json' }, + copilot: { skills: '.github/skills', mcp: '.copilot/mcp-config.json', mcpProject: '.github/mcp.json' }, + }; + const shared = path.join(projectRoot, 'shared', 'mcp.json'); + await fse.outputJson(shared, { + x: { type: 'http', url: 'https://x.example/mcp', headers: { Authorization: 'Bearer t0ken-of-copilot' } }, + mcpServers: { x: { type: 'http', url: 'https://x.example/mcp' } }, + }); + expect(await trackResolvedMcpFiles(localConfig, [{ tool: 'copilot', file: shared }])).toBe('written'); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('cursor', true)]: [{ name: 'x', hash: 'fixture-hash', resolved: false }], + // Copilot's record describes the file its mapping reaches today, not this one. + [managedMcpManifestKey('copilot', true)]: [], + }); + // Cursor's x is still the team's, now a literal: its own rules find nothing to keep. + await writeTeamMcp('servers:\n - name: x\n transport: http\n url: https://x.example/mcp\n'); + await fse.appendFile(path.join(projectRoot, '.git', 'info', 'exclude'), '/.mcp.json\n'); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix ?? '').toContain(shared); + }); + describe('for an HTTP-backed team, judged by the records the local agent wrote', () => { const writeRecord = (record: Record): Promise => fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { [managedMcpManifestKey('claude', true)]: [record] }); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 9fb605907..6ee3d8c35 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1010,6 +1010,29 @@ servers: expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); }); + it('never lets a tool that maps a moved Copilot\'s file today claim its stale bare server by the name it owns under mcpServers', async () => { + const copilot = { skills: '.github/skills', mcp: '.copilot/mcp-config.json' }; + const before = { ...teamConfig, toolPaths: { ...TOOL_PATHS, copilot: { ...copilot, mcpProject: '.mcp.json' } } } as TeamaiConfig; + const after = { ...teamConfig, toolPaths: { ...TOOL_PATHS, copilot: { ...copilot, mcpProject: '.github/mcp.json' } } } as TeamaiConfig; + await fse.ensureDir(path.join(projectRoot, '.github', 'skills')); + // An empty file reads as Copilot's bare map: its write stays bare. + await fse.writeFile(path.join(projectRoot, '.mcp.json'), ''); + await writeMcpYaml(`${withSecret} tools: [copilot]\n`); + await reconcileMcpForConfig(before, projectConfig); + expect((await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record)['with-secret']).toBeDefined(); + // Copilot moves to .github/mcp.json; Claude, on .mcp.json, now owns a literal with-secret under mcpServers. + await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'published-literal')} tools: [claude]\n`); + vi.stubEnv('SECRET_TOKEN', ''); + await fse.writeFile(path.join(projectRoot, '.git', 'info', 'exclude'), ''); + + await reconcileMcpForConfig(after, projectConfig); + + const doc = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; + expect((doc.mcpServers as Record)['with-secret']).toBeDefined(); + expect(JSON.stringify(doc['with-secret'])).toContain('super-secret-value'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + }); + it('never lets one format\'s record claim a server of the same name under another format\'s key', async () => { const toolPaths = { ...UNMOVED_TOOL_PATHS, diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 64bdc72f6..797641149 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -916,9 +916,9 @@ export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: McpO } // Each target's key read alone: another key's owner proves nothing of it (OpenCode's `mcp` beside `mcpServers`). for (const target of targets) { - const own = await mcpFileState([target]); - const names = own.kind === 'parsed' ? own.servers : []; - const other = names.find((name) => !owned(target).includes(name)); + const { underKey, bare } = await serverNamesByPlacement(target); + const other = underKey.find((name) => !owned(target).includes(name)) + ?? bare.find((name) => !owned(target, { bare: true }).includes(name)); if (other !== undefined) { return `teamai may have written a resolved value to it for ${targets.map((t) => t.tool).join(', ')} under an earlier toolPaths mapping, ` + `and it holds ${other}, which no tool that maps it now owns`; @@ -927,8 +927,26 @@ export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: McpO return null; } -/** For a target of a file other tools map today, the servers their records own under its key. */ -export type McpOwnedFor = (target: McpTarget) => readonly string[]; +/** + * `target`'s server names under its format's key, and apart, a Copilot project file's bare ones: a name + * another tool owns under `mcpServers` says nothing of a bare server beside it (#882). + */ +async function serverNamesByPlacement(target: McpTarget): Promise<{ underKey: string[]; bare: string[] }> { + if (target.format !== 'copilot' || !target.projectScope) { + return { underKey: [...(await installedMcpEntries(target))?.keys() ?? []], bare: [] }; + } + const doc = await readJsonDoc(target.file, MCP_SERVER_KEY.copilot, true); + if (!doc) return { underKey: [], bare: [] }; + return doc.bare + ? { underKey: [], bare: Object.keys(doc.servers) } + : { underKey: Object.keys(doc.servers), bare: Object.keys(doc.beside ?? {}) }; +} + +/** + * For a target of a file other tools map today, the servers their records own under its key, or, with + * `bare`, at a Copilot project file's top level, where only Copilot writes. + */ +export type McpOwnedFor = (target: McpTarget, options?: { bare?: boolean }) => readonly string[]; /** * `McpOwnedFor` from `mappedBy`, the tools a file's mapping reaches today: only the records of those that @@ -936,8 +954,11 @@ export type McpOwnedFor = (target: McpTarget) => readonly string[]; */ export function ownedByMappers(mappedBy: readonly string[], manifest: ManagedMcpManifest | undefined): McpOwnedFor | undefined { if (mappedBy.length === 0) return undefined; - return (target) => mappedBy - .filter((tool) => { const format = detectMcpFormat(tool); return format !== null && sameServerKey(format, target.format); }) + return (target, options = {}) => mappedBy + .filter((tool) => { + const format = detectMcpFormat(tool); + return format !== null && (options.bare ? format === 'copilot' : sameServerKey(format, target.format)); + }) .flatMap((tool) => manifest?.[managedMcpManifestKey(tool, true)] ?? []).map((record) => record.name); } From 9c3333d9f5fc462534b221f72ae2cd6161498e7a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 10:22:50 +0200 Subject: [PATCH 81/85] fix(mcp): have the local agent's protection read CodeBuddy's former default and a bare Copilot entry apart (#882) An HTTP team has no teamai.yaml history, so localAgentCredentialFiles never visited a built-in default teamai has since changed: a credential a local agent from before 57636a27 wrote to .codebuddy/mcp.json stayed committable. It now also reads EARLIER_BUILTIN_MCP_PROJECT (earlierMappedMcpTargets with history: false), in sync and doctor alike. It also judged a Copilot project file through installedMcpEntries, which merges bare servers with mcpServers by name, the keyed one winning. A tokenized bare entry beside a credential-free mcpServers entry of its name, both under one record, went unseen. Each entry is now judged on its own (mcpEntriesByPlacement, which recordedMcpFileEvidence uses too). --- src/__tests__/doctor-mcp-delivery.test.ts | 13 ++++ src/__tests__/local-agent-mcp.test.ts | 40 +++++++++++ src/mcp-reconcile.ts | 81 +++++++++++++---------- 3 files changed, 98 insertions(+), 36 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 81ac2b1f3..12c827b6b 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -454,6 +454,19 @@ describe('doctor — MCP servers delivered on disk', () => { expect(await check.check()).toBe(false); }); + it('fails for a credential a local agent from before 57636a27 wrote at CodeBuddy\'s former .codebuddy/mcp.json', async () => { + const old = path.join(projectRoot, '.codebuddy', 'mcp.json'); + await fse.outputJson(old, { mcpServers: { clawpro: { type: 'http', url: 'https://clawpro.example/mcp', headers: { Authorization: 'Bearer t0ken' } } } }); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), { + [managedMcpManifestKey('codebuddy', true)]: [{ name: 'clawpro', hash: 'h' }], + }); + + const check = await excludeCheck(); + if (!check) throw new Error('no git exclude check'); + expect(await check.check()).toBe(false); + expect(check.fix ?? '').toContain(old); + }); + it('has nothing to say of a file whose recorded server carries neither header nor env value', async () => { const jira = { type: 'http', url: 'https://jira.example/mcp' }; await fse.writeJson(path.join(projectRoot, '.mcp.json'), { mcpServers: { jira } }); diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 8260310cc..0ce8db8d5 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -676,6 +676,46 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(await fse.readFile(path.join(wsPath, '.mcp.json'), 'utf-8')).toContain('bmcp-test-token'); }); + // As an older local agent left it: no line, no managed-mcp-files.json, no note on the record. + const asAnOlderAgentLeftIt = async (tool: string): Promise => { + await fse.writeFile(path.join(wsPath, '.git', 'info', 'exclude'), ''); + await fse.remove(await workspaceFile('managed-mcp-files.json')); + const manifestFile = await workspaceFile('managed-mcp.json'); + const manifest = await fse.readJson(manifestFile) as Record>; + manifest[`${tool}:project`] = manifest[`${tool}:project`].map(({ name, hash }) => ({ name, hash })); + await fse.writeJson(manifestFile, manifest); + }; + + it('lists the config an agent from before 57636a27 wrote at CodeBuddy\'s former .codebuddy/mcp.json, on the next sync', async () => { + await install(9107, bearer); + await asAnOlderAgentLeftIt('codebuddy'); + await fse.move(path.join(wsPath, '.mcp.json'), path.join(wsPath, '.codebuddy', 'mcp.json')); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.codebuddy/mcp.json')).not.toBe(''); + + await runResponse({ cmds: [] }, 'codebuddy', wsPath); + + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toMatch(/^\/\.codebuddy\/mcp\.json$/m); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.codebuddy/mcp.json')).toBe(''); + }); + + it('lists a Copilot config whose bare entry holds a credential beside a credential-free one of its name under mcpServers', async () => { + const configFile = path.join(wsPath, '.github', 'mcp.json'); + await fse.outputJson(configFile, {}); + const acks = await runResponse({ + cmds: [{ id: 9108, type: 'install_mcp', scope: 'workspace', workspace_path: wsPath, slug: 'clawpro', version: '1.0.0', mcp_config: bearer }], + }, 'copilot'); + expect(acks[0].status).toBe('success'); + await asAnOlderAgentLeftIt('copilot'); + const doc = await fse.readJson(configFile) as Record; + expect(JSON.stringify(doc.clawpro)).toContain('bmcp-test-token'); + await fse.writeJson(configFile, { ...doc, mcpServers: { clawpro: { command: 'clawpro-mcp' } } }); + + await runResponse({ cmds: [] }, 'copilot', wsPath); + + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toMatch(/^\/\.github\/mcp\.json$/m); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.github/mcp.json')).toBe(''); + }); + it('still lists that config when an install replacing its entry with a bare command cannot write the file', async () => { await install(9105, bearer); await fse.writeFile(path.join(wsPath, '.git', 'info', 'exclude'), ''); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 797641149..bae75da10 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -839,16 +839,20 @@ const EARLIER_BUILTIN_MCP_PROJECT = { export async function earlierMappedMcpTargets( cfg: LocalConfig, known: McpTarget[], + /** `history: false`: only the built-in defaults, for a team with no teamai.yaml (HTTP-backed). */ + options: { history?: boolean } = {}, ): Promise | null> { const { projectRoot } = cfg; if (!projectRoot) return []; const repoPath = cfg.repo.localPath; - let revisions: string[]; - try { - revisions = (await createGit(repoPath).raw(['log', '--format=%H', 'HEAD', '--', 'teamai.yaml'])).split('\n').filter(Boolean); - } catch (e) { - log.debug(`Could not read the history of teamai.yaml in ${repoPath}: ${e instanceof Error ? e.message : String(e)}. The next pull tries again.`); - return null; + let revisions: string[] = []; + if (options.history !== false) { + try { + revisions = (await createGit(repoPath).raw(['log', '--format=%H', 'HEAD', '--', 'teamai.yaml'])).split('\n').filter(Boolean); + } catch (e) { + log.debug(`Could not read the history of teamai.yaml in ${repoPath}: ${e instanceof Error ? e.message : String(e)}. The next pull tries again.`); + return null; + } } const root = await realFilePath(projectRoot); // Each path, by real path, with the tools today's targets or the record reach it for. @@ -916,9 +920,9 @@ export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: McpO } // Each target's key read alone: another key's owner proves nothing of it (OpenCode's `mcp` beside `mcpServers`). for (const target of targets) { - const { underKey, bare } = await serverNamesByPlacement(target); - const other = underKey.find((name) => !owned(target).includes(name)) - ?? bare.find((name) => !owned(target, { bare: true }).includes(name)); + const placed = await mcpEntriesByPlacement(target); + const other = [...placed?.keyed.keys() ?? []].find((name) => !owned(target).includes(name)) + ?? [...placed?.bare.keys() ?? []].find((name) => !owned(target, { bare: true }).includes(name)); if (other !== undefined) { return `teamai may have written a resolved value to it for ${targets.map((t) => t.tool).join(', ')} under an earlier toolPaths mapping, ` + `and it holds ${other}, which no tool that maps it now owns`; @@ -928,18 +932,19 @@ export async function recordedMcpFileEvidence(targets: McpTarget[], owned?: McpO } /** - * `target`'s server names under its format's key, and apart, a Copilot project file's bare ones: a name - * another tool owns under `mcpServers` says nothing of a bare server beside it (#882). + * `target`'s servers under its format's key, and apart, a Copilot project file's bare ones, or null when + * the file does not parse (#882). `installedMcpEntries` merges the two by name, the keyed one winning: a + * bare server beside one of its name under `mcpServers` is judged on its own here. */ -async function serverNamesByPlacement(target: McpTarget): Promise<{ underKey: string[]; bare: string[] }> { +async function mcpEntriesByPlacement(target: McpTarget): Promise<{ keyed: Map; bare: Map } | null> { if (target.format !== 'copilot' || !target.projectScope) { - return { underKey: [...(await installedMcpEntries(target))?.keys() ?? []], bare: [] }; + const keyed = await installedMcpEntries(target); + return keyed && { keyed, bare: new Map() }; } const doc = await readJsonDoc(target.file, MCP_SERVER_KEY.copilot, true); - if (!doc) return { underKey: [], bare: [] }; - return doc.bare - ? { underKey: [], bare: Object.keys(doc.servers) } - : { underKey: Object.keys(doc.servers), bare: Object.keys(doc.beside ?? {}) }; + if (!doc) return null; + const entries = (servers: Record | undefined): Map => new Map(Object.entries(servers ?? {})); + return doc.bare ? { keyed: new Map(), bare: entries(doc.servers) } : { keyed: entries(doc.servers), bare: entries(doc.beside) }; } /** @@ -1288,12 +1293,14 @@ async function protectResolvedMcpConfigs( } /** - * The targets among `targets`, and those managed-mcp-files.json recorded under - * a mapping another teamai.yaml made, whose project MCP config may hold a - * credential an HTTP-backed team's local agent wrote (#882). No mcp.yaml to - * judge by: a server its install recorded as carrying a credential, or an - * older install's entry carrying one (a header, env value, argument or URL), - * or one whose record was lost while another server's remains. With no record + * The targets among `targets`, those managed-mcp-files.json recorded under + * a mapping another teamai.yaml made, and the built-in defaults teamai has + * since changed, whose project MCP config may hold a credential an HTTP-backed + * team's local agent wrote (#882). No mcp.yaml to judge by: a server its + * install recorded as carrying a credential, or an older install's entry + * carrying one (a header, env value, argument or URL), or one whose record was + * lost while another server's remains. Each entry is judged on its own, a + * Copilot file's bare one apart from the one of its name under mcpServers. With no record * of the tool at all, a file managed-mcp-files.json lists holds while it holds * any server, or doesn't parse: nothing says which of them the local agent * wrote. A file two tools map may appear once for each. Read-only. @@ -1304,25 +1311,27 @@ export async function localAgentCredentialFiles(localConfig: LocalConfig, target const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); const ledger = (await readResolvedMcpFiles(localConfig)).files; const recorded = [...(await recordedMcpTargets(localConfig, targets)).values()].flatMap((file) => file.targets); + // An older agent wrote to a built-in default teamai has since changed; an HTTP team has no teamai.yaml history. + const earlier = await earlierMappedMcpTargets(localConfig, targets, { history: false }) ?? []; const held: McpTarget[] = []; - for (const target of [...targets, ...recorded]) { + for (const target of [...targets, ...recorded, ...earlier]) { if (!await pathExists(target.file)) continue; - const installed = await installedMcpEntries(target); + const placed = await mcpEntriesByPlacement(target); + const entries = placed && [...placed.keyed, ...placed.bare]; const records = manifest[managedMcpManifestKey(target.tool, true)]; if (records === undefined) { - if (ledger[target.file] !== undefined && (installed === null || installed.size > 0)) held.push(target); + if (ledger[target.file] !== undefined && (entries === null || entries.length > 0)) held.push(target); continue; } - const recordedNames = new Set(records.map((record) => record.name)); - const credential = records.some((record) => { - if (installed === null) return record.resolved !== false; - if (!installed.has(record.name)) return false; - const entry = installed.get(record.name); - // `resolved: false` speaks for the entry its install wrote: an older one a failed write left is judged by what it holds. - const noted = record.resolved === true || entryHash(entry) === record.hash ? record.resolved : undefined; - return noted ?? carriesLocalAgentCredential(entry); - }) - || (installed !== null && [...installed].some(([name, entry]) => !recordedNames.has(name) && carriesLocalAgentCredential(entry))); + const byName = new Map(records.map((record) => [record.name, record])); + const credential = entries === null + ? records.some((record) => record.resolved !== false) + : entries.some(([name, entry]) => { + const record = byName.get(name); + // `resolved: false` speaks for the entry its install wrote: an older one a failed write left is judged by what it holds. + const noted = record && (record.resolved === true || entryHash(entry) === record.hash) ? record.resolved : undefined; + return noted ?? carriesLocalAgentCredential(entry); + }); if (credential) held.push(target); } return held; From 4e71c7a6ea00379abc5ae1243b2962ccbb97322d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 11:00:27 +0200 Subject: [PATCH 82/85] fix(mcp): prove bare ownership and persist installs before exclusions (#882) --- docs/designs/data-directory-layout.md | 2 +- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + skill-data/core/references/troubleshooting.md | 2 + src/__tests__/local-agent-mcp.test.ts | 41 +++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 19 ++++++++- src/local-agent.ts | 20 ++++++--- src/mcp-reconcile.ts | 13 +++--- src/types.ts | 2 + 9 files changed, 91 insertions(+), 12 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 586ee921c..43fb28510 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -439,7 +439,7 @@ every checkout, so that is where they live now: ├── reports-wt/ (the side-branch locks sit beside them) ├── pending-learnings/ pendingLearningsDir → /pending-learnings └── workspaces// - ├── managed-mcp.json managedMcpManifestPath, one per checkout + ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot records mark completed bare writes with bare: true ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882); │ for an HTTP team, the configs the local agent wrote a credential to diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4289c8ca2..91ea6b7d5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1245,6 +1245,8 @@ precedence. For an existing team that pins run servers in either file; TeamAI does not migrate or delete the old file. Claude Code also reads the root `.mcp.json`, so this file is shared by both tools. +TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. HTTP local-agent installs check Git protection without changing it, persist ownership, then add the exclusion and file record before writing a credential. A failed ownership write changes neither Git exclusions nor the MCP config. + Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, remote transports keep `http` or `sse`, and every managed entry gets the required `tools: ["*"]` allowlist. TeamAI honors `COPILOT_HOME`; project configuration uses Copilot CLI's documented `.github/mcp.json` repository location. See [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers). Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. Kiro supports the same `mcpServers` format in its dedicated, mcpServers-only `.kiro/settings/mcp.json` (see [Kiro's MCP configuration docs](https://kiro.dev/docs/mcp/configuration/)). OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. 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, in `mcp.yaml`. A key the team declares in `env/secrets.yaml` resolves from your value for this team (`teamai env set`), then your value for the machine (`teamai env set --global`), then your own environment, which leaves out values a teamai `env.sh` exported (see [Team secrets](designs/team-secrets.md#resolution)). Any other variable resolves from your value for this team (`teamai env set KEY`), then from the team env variables this directory receives (`env/env.yaml` and the active `env//env.yaml`); the environment fills only a key the team sets nothing for, and no longer overrides a team variable (see [Team secrets](designs/team-secrets.md#variables)). An interactive `pull` and `teamai doctor` say when your export differs from the team's value and is ignored. Unresolved variables skip the server with a hint. A declared secret is different: when a pull can't find it, the entry an earlier pull wrote stays as it is, so it may hold a value that was since rotated, until a pull finds the new one (see [Team secrets](designs/team-secrets.md#a-missing-secret-keeps-the-mcp-entry)). An interactive `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and `teamai env exec` name a declared secret with no value, the servers that use it and the command that sets it: `` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (). `` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 183bb9c98..2163349a9 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1126,6 +1126,8 @@ CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp) TeamAI 不会迁移或删除旧文件。Claude Code 也读取根目录的 `.mcp.json`, 因此两个工具共享该文件。 +TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。HTTP 本地代理安装先只读检查 Git 保护,再保存所有权,随后添加排除规则和文件记录,最后写入凭据。所有权写入失败不会改变 Git 排除规则或 MCP 配置。 + Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远程传输保留 `http` 或 `sse`,每个 TeamAI 管理的条目都会带上必需的 `tools: ["*"]` 允许列表。TeamAI 遵循 `COPILOT_HOME`,项目配置使用 Copilot CLI 官方文档指定的 `.github/mcp.json` 仓库路径。详见 [GitHub Copilot CLI 添加 MCP Server](https://docs.github.com/zh/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)。Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。Kiro 在专用的、只含 `mcpServers` 的 `.kiro/settings/mcp.json` 中使用同一格式(见 [Kiro MCP 配置文档](https://kiro.dev/docs/mcp/configuration/))。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 **密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。团队在 `env/secrets.yaml` 中声明的 key 优先取你为该团队设置的值(`teamai env set`),其次取你为本机设置的值(`teamai env set --global`),再次取你自己的环境,不包括 teamai `env.sh` 导出的值(见[团队密钥](designs/team-secrets.zh-CN.md#解析顺序))。其他变量优先取你为该团队设置的值(`teamai env set KEY`),其次是该目录收到的团队环境变量(`env/env.yaml` 与活动的 `env//env.yaml`);环境只补充团队没有设置的 key,不再覆盖团队变量(见[团队密钥](designs/team-secrets.zh-CN.md#变量))。你导出的值与团队的值不同而被忽略时,交互式 `pull` 和 `teamai doctor` 会指出。变量无法解析则跳过并提示。已声明的密钥不同:pull 找不到它时,之前某次 pull 写入的条目原样保留,因此里面可能是已经轮换掉的旧值,直到某次 pull 找到新值(见[团队密钥](designs/team-secrets.zh-CN.md#缺少密钥时保留-mcp-条目))。交互式 `pull`、`teamai mcp list`、`teamai env list`、`teamai doctor` 和 `teamai env exec` 会指出没有值的已声明密钥、用到它的 server 以及设置它的命令:`` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (). `` diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 6fb169cc1..5b3228be2 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -94,6 +94,8 @@ ever committed with it; then `teamai pull`. Do not run `git rm` or commit for them. For an exclude file that is not writable, one another teamai command held, or a git error, relay the fix the line gives. +For HTTP local-agent MCP installs, a failed ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. + ## Permission / access denied `init`, `pull`, or `push` failing with a permission error usually means the user diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 0ce8db8d5..d0a16a122 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -205,6 +205,24 @@ describe('local-agent: MCP install/uninstall commands', () => { expect((doc.mcpServers as Record)[COPILOT_SERVER]).toEqual(expect.objectContaining({ url: 'https://new.example.com/mcp' })); }); + it('preserves an identical member-owned bare Copilot entry through install, update and uninstall', async () => { + const workspacePath = path.join(tmpDir, 'copilot-identical-member'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + const mine = { type: 'http', tools: ['*'], url: 'https://copilot.example.com/mcp' }; + await fse.ensureDir(path.dirname(configFile)); + await fse.writeJson(configFile, { [COPILOT_SERVER]: mine, mcpServers: {} }); + for (const [id, type] of [[8995, 'install_mcp'], [8996, 'install_mcp'], [8997, 'uninstall_mcp']] as const) { + const acks = await runResponse({ cmds: [{ + id, type, scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', + mcp_config: { transport: 'http', url: mine.url }, + }] }, 'copilot'); + expect(acks[0].status).toBe('success'); + expect((await fse.readJson(configFile))[COPILOT_SERVER]).toEqual(mine); + } + expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER]).toBeUndefined(); + }); + it('rejects an unmanaged collision in a bare Copilot project map', async () => { const workspacePath = path.join(tmpDir, 'copilot-collision-project'); const configFile = path.join(workspacePath, '.github', 'mcp.json'); @@ -596,6 +614,29 @@ describe('local-agent: MCP install/uninstall commands', () => { expect(manifest['codebuddy:project']).toEqual([expect.objectContaining({ name: 'clawpro', resolved: true })]); }); + it('leaves a user config visible to git when the ownership manifest cannot be written', async () => { + const original = { mcpServers: { mine: { command: 'my-server' } } }; + await fse.writeJson(path.join(wsPath, '.mcp.json'), original); + const excludeBefore = await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8'); + const fs = await import('../utils/fs.js'); + const write = fs.writeJsonAtomic; + vi.spyOn(fs, 'writeJsonAtomic').mockImplementation(async (file, ...args) => { + if (file.endsWith('/managed-mcp.json')) throw new Error('simulated manifest write failure'); + return write(file, ...args); + }); + + const acks = await install(9110, bearer); + + expect(acks[0].status).toBe('failed'); + expect(acks[0].error).toContain('simulated manifest write failure'); + expect(await fse.readJson(path.join(wsPath, '.mcp.json'))).toEqual(original); + expect(await fse.readFile(path.join(wsPath, '.git', 'info', 'exclude'), 'utf-8')).toBe(excludeBefore); + expect(git('status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).toContain('?? .mcp.json'); + const wsDir = path.join(wsPath, '.teamai', 'workspaces'); + const ids = await fse.pathExists(wsDir) ? await fse.readdir(wsDir) : []; + for (const id of ids) expect(await fse.pathExists(path.join(wsDir, id, 'managed-mcp-files.json'))).toBe(false); + }); + it('withholds it from a config git tracks, naming why, and leaves the file and its records as they were', async () => { const original = { mcpServers: { mine: { type: 'http', url: 'https://mine.example.com/mcp' } } }; await fse.writeJson(path.join(wsPath, '.mcp.json'), original); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 6ee3d8c35..35b0c2b4a 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1147,6 +1147,23 @@ servers: expect(first.mcpServers).toBeUndefined(); }); + it('does not infer bare ownership from an unchanged entry with a legacy record', async () => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile); + delete manifest['copilot:project'][0].bare; + await fse.writeJson(manifestFile, manifest); + const bare = (await fse.readJson(path.join(projectRoot, '.mcp.json')))['with-secret']; + + await reconcileMcpForConfig(shared(), projectConfig); + expect((await fse.readJson(manifestFile))['copilot:project'][0].bare).toBeUndefined(); + await writeMcpYaml(open); + await reconcileMcpForConfig(shared(), { ...projectConfig, disabledAgents: ['copilot'] } as LocalConfig); + await reconcileMcpForConfig(shared(), projectConfig); + + expect((await fse.readJson(path.join(projectRoot, '.mcp.json')))['with-secret']).toEqual(bare); + }); + it('keeps its line, pull after pull, while Copilot\'s bare entry holds the value beside the mcpServers Claude wrote', async () => { await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); // No longer set: only the entry, not a scan for the value, says what the file holds. @@ -1177,7 +1194,7 @@ servers: }); it('never removes a bare server of the member\'s own that shares a name with one teamai writes under mcpServers', async () => { - const mine = { type: 'http', url: 'https://mine.example/mcp' }; + const mine = { type: 'http', tools: ['*'], url: 'https://jira.example/mcp' }; const doc = await fse.readJson(path.join(projectRoot, '.mcp.json')) as Record; await fse.writeJson(path.join(projectRoot, '.mcp.json'), { ...doc, jira: mine }); const jira = ' - name: jira\n transport: http\n url: https://jira.example/mcp\n tools: [copilot]\n'; diff --git a/src/local-agent.ts b/src/local-agent.ts index 99d7e238a..6bdff8b2a 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2848,10 +2848,11 @@ function updateManifestRecord( hash: string, /** Project scope: whether the entry carries a credential, as `resolved` notes for a pull's (#882). */ resolved?: boolean, + bare?: boolean, ): void { const records = manifest[key] ?? []; const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); - const record: ManagedMcpRecord = { name, hash, ...resolved === undefined ? {} : { resolved } }; + const record: ManagedMcpRecord = { name, hash, ...resolved === undefined ? {} : { resolved }, ...bare ? { bare: true } : {} }; if (idx >= 0) { records[idx] = record; } else { @@ -2945,13 +2946,19 @@ async function installMcpServer( // The copy a bare install left before another tool added the key would keep the old value beside this one (#882). // Judged by the record as it was before this install updates it. const bareCopy = isTeamaiBareCopy(doc, slug, owned); - // A credential lands in a project config only once git leaves the file out of a commit, as a pull's does (#882). - const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); + // Check Git without changing it until ownership is persisted. Recheck protection before writing the credential (#882). + const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry, true); updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined); await writeJsonAtomic(manifestPath, manifest); + if (credential) await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); if (bareCopy) delete doc.data[slug]; doc.servers[slug] = entry; await writeJsonDoc(targetFile, serverKey, doc); + if (doc.bare) { + // Placement is evidence of a completed write, not just an attempted install. + updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, true); + await writeJsonAtomic(manifestPath, manifest); + } } log.debug(`local-agent: installed MCP server "${slug}" for ${tool} (scope=${scope})`); return command.version; @@ -2961,7 +2968,8 @@ async function installMcpServer( * For a project-scope install: whether `entry` carries a credential and, if * so, list `file` in `.git/info/exclude` and record it in * managed-mcp-files.json, as a pull does before writing a resolved value - * (#882). Throws, before anything is written, when git would commit the file. + * (#882). With dryRun, checks protection without adding an exclusion or file record. + * Throws when git protection fails; the MCP config is left unchanged. */ async function keepCredentialOutOfGit( localConfig: LocalConfig, @@ -2969,17 +2977,19 @@ async function keepCredentialOutOfGit( slug: string, file: string, entry: unknown, + dryRun = false, ): Promise { const { carriesLocalAgentCredential, ensureExcludedFromGit } = await import('./mcp-git-exclude.js'); if (!carriesLocalAgentCredential(entry)) return false; // Commands come from the server: no pull replays one. - const exclusion = await ensureExcludedFromGit(file, { rerun: 'install the MCP server again' }); + const exclusion = await ensureExcludedFromGit(file, { dryRun, rerun: 'install the MCP server again' }); if (exclusion.kind === 'failed') { throw new Error( `install_mcp: withheld "${slug}" from ${file}: it may carry a credential (a header, env value, argument or URL), and teamai could not keep the file ` + `out of git: ${exclusion.reason}. The file is left as it was. ${exclusion.fix}`, ); } + if (dryRun) return true; const { trackResolvedMcpFiles } = await import('./mcp-resolved-files.js'); // A failure does not stop the write: the exclusion protects the file. const result = await trackResolvedMcpFiles(localConfig, [{ tool, file }]).catch((e: unknown) => e instanceof Error ? e.message : String(e)); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index bae75da10..1df0e4165 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -386,8 +386,8 @@ export interface JsonDoc { /** The existing document stores server names directly at the top level. */ bare: boolean; /** - * A Copilot project file holding `serverKey` as well: the servers at its top level beside it, which a bare - * write left before another tool added the key (#882). Read and removed, never written to. + * A Copilot project file holding `serverKey` as well: the servers at its top level beside it. + * These may belong to the member or come from a previous bare write (#882). */ beside?: Record; } @@ -1777,12 +1777,12 @@ async function forgetUnwrittenMcpConfigs(localConfig: LocalConfig, targets: McpT /** * Whether the bare Copilot server `name` beside `mcpServers` is the copy a teamai write left before another tool - * added the key (#882): exactly what `owned`'s record says teamai wrote. A member's own server of that name, + * added the key (#882): a completed bare write in `owned`, with matching content. A member's own server of that name, * or one edited since, is left alone. */ export function isTeamaiBareCopy(doc: { beside?: Record }, name: string, owned: readonly ManagedMcpRecord[]): boolean { const bare = doc.beside?.[name]; - return bare !== undefined && owned.some((record) => record.name === name && record.hash === entryHash(bare)); + return bare !== undefined && owned.some((record) => record.name === name && record.bare === true && record.hash === entryHash(bare)); } // ─── Appliers ──────────────────────────────────────────────── @@ -1822,7 +1822,9 @@ async function applyJson( }); continue; } - nextRecords.push({ name, hash }); + const record: ManagedMcpRecord = { name, hash }; + if (doc.bare && owned.some((r) => r.name === name && r.bare === true)) record.bare = true; + nextRecords.push(record); holdsResolvedValue ||= resolvedValue; // The copy a bare write left before another tool added the key would keep the old value beside this one (#882). if (isTeamaiBareCopy(doc, name, owned)) { @@ -1831,6 +1833,7 @@ async function applyJson( } if (existing !== undefined && ownedHash.get(name) === hash) continue; doc.servers[name] = entry; + if (doc.bare) record.bare = true; dirty = true; changes.push({ tool: target.tool, server: name, action: existing === undefined ? 'added' : 'updated' }); } diff --git a/src/types.ts b/src/types.ts index b6a78f911..5cbe5cfa7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -904,6 +904,8 @@ export interface ManagedMcpRecord { * (#882). Absent in records an older teamai wrote. */ resolved?: boolean; + /** TeamAI wrote this entry in Copilot's bare project map. Absent means unproven. */ + bare?: true; /** * Project scope: this record was rebuilt after it was lost, or written by a * pull that found no managed-mcp.json, and the other servers in its file From 1a4ddfa18858980be6283ce5b66a87808242a3ae Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 11:53:34 +0200 Subject: [PATCH 83/85] fix(mcp): keep bare ownership from claiming keyed Copilot entries (#882) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/troubleshooting.md | 2 +- src/__tests__/local-agent-mcp.test.ts | 28 +++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 23 +++++++++++++++ src/local-agent.ts | 8 ++++-- src/mcp-reconcile.ts | 16 ++++++++--- 7 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 91ea6b7d5..003477ae5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1245,7 +1245,7 @@ precedence. For an existing team that pins run servers in either file; TeamAI does not migrate or delete the old file. Claude Code also reads the root `.mcp.json`, so this file is shared by both tools. -TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. HTTP local-agent installs check Git protection without changing it, persist ownership, then add the exclusion and file record before writing a credential. A failed ownership write changes neither Git exclusions nor the MCP config. +TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. A bare ownership record does not authorize changes to a same-named member entry under `mcpServers`; update skips that collision and removal cleans only the owned bare copy. HTTP local-agent installs check Git protection without changing it, persist ownership, then add the exclusion and file record before writing a credential. A failed ownership write changes neither Git exclusions nor the MCP config. Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, remote transports keep `http` or `sse`, and every managed entry gets the required `tools: ["*"]` allowlist. TeamAI honors `COPILOT_HOME`; project configuration uses Copilot CLI's documented `.github/mcp.json` repository location. See [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers). Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. Kiro supports the same `mcpServers` format in its dedicated, mcpServers-only `.kiro/settings/mcp.json` (see [Kiro's MCP configuration docs](https://kiro.dev/docs/mcp/configuration/)). OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2163349a9..b75a2fe30 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1126,7 +1126,7 @@ CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp) TeamAI 不会迁移或删除旧文件。Claude Code 也读取根目录的 `.mcp.json`, 因此两个工具共享该文件。 -TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。HTTP 本地代理安装先只读检查 Git 保护,再保存所有权,随后添加排除规则和文件记录,最后写入凭据。所有权写入失败不会改变 Git 排除规则或 MCP 配置。 +TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。顶层所有权记录不授权修改 `mcpServers` 下的同名成员条目;更新跳过该冲突,移除时只清理受管理的顶层副本。HTTP 本地代理安装先只读检查 Git 保护,再保存所有权,随后添加排除规则和文件记录,最后写入凭据。所有权写入失败不会改变 Git 排除规则或 MCP 配置。 Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远程传输保留 `http` 或 `sse`,每个 TeamAI 管理的条目都会带上必需的 `tools: ["*"]` 允许列表。TeamAI 遵循 `COPILOT_HOME`,项目配置使用 Copilot CLI 官方文档指定的 `.github/mcp.json` 仓库路径。详见 [GitHub Copilot CLI 添加 MCP Server](https://docs.github.com/zh/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)。Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。Kiro 在专用的、只含 `mcpServers` 的 `.kiro/settings/mcp.json` 中使用同一格式(见 [Kiro MCP 配置文档](https://kiro.dev/docs/mcp/configuration/))。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 5b3228be2..f0341a91a 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -94,7 +94,7 @@ ever committed with it; then `teamai pull`. Do not run `git rm` or commit for them. For an exclude file that is not writable, one another teamai command held, or a git error, relay the fix the line gives. -For HTTP local-agent MCP installs, a failed ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. +For HTTP local-agent MCP installs, a failed ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. A bare ownership record cannot claim a same-named member entry under `mcpServers`: updates skip the collision, and removal leaves that keyed entry alone. ## Permission / access denied diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index d0a16a122..a0829d350 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -223,6 +223,34 @@ describe('local-agent: MCP install/uninstall commands', () => { expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER]).toBeUndefined(); }); + it.each(['install_mcp', 'uninstall_mcp'] as const)('preserves a member-owned keyed Copilot entry after a bare install during %s', async (type) => { + const workspacePath = path.join(tmpDir, 'copilot-keyed-member'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(path.dirname(configFile)); + await fse.writeFile(configFile, ''); + const command = { + id: 9010, type: 'install_mcp', scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', + mcp_config: { transport: 'http', url: 'https://team.example/mcp' }, + }; + expect((await runResponse({ cmds: [command] }, 'copilot'))[0].status).toBe('success'); + const doc = await fse.readJson(configFile); + const mine = { type: 'http', tools: ['*'], url: 'https://member.example/mcp' }; + await fse.writeJson(configFile, { ...doc, mcpServers: { [COPILOT_SERVER]: mine } }); + + const acks = await runResponse({ cmds: [{ ...command, id: 9011, type }] }, 'copilot'); + + expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER]).toEqual(mine); + if (type === 'install_mcp') { + expect(acks[0].status).toBe('failed'); + expect(acks[0].error).toContain('not managed by teamai'); + expect((await fse.readJson(configFile))[COPILOT_SERVER]).toEqual(doc[COPILOT_SERVER]); + } else { + expect(acks[0].status).toBe('success'); + expect((await fse.readJson(configFile))[COPILOT_SERVER]).toBeUndefined(); + } + }); + it('rejects an unmanaged collision in a bare Copilot project map', async () => { const workspacePath = path.join(tmpDir, 'copilot-collision-project'); const configFile = path.join(workspacePath, '.github', 'mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 35b0c2b4a..8a20ffacb 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1164,6 +1164,29 @@ servers: expect((await fse.readJson(path.join(projectRoot, '.mcp.json')))['with-secret']).toEqual(bare); }); + it.each(['update', 'drop', 'remove', 'missing-secret'] as const)('preserves a member-owned keyed Copilot entry after a bare write during %s', async (action) => { + const file = path.join(projectRoot, '.mcp.json'); + const mine = { type: 'http', tools: ['*'], url: 'https://member.example/mcp' }; + const doc = await fse.readJson(file); + await fse.writeJson(file, { ...doc, mcpServers: { 'with-secret': mine } }); + if (action === 'update') await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'new-team-value')} tools: [copilot]\n`); + if (action === 'drop') await writeMcpYaml('servers: []\n'); + if (action === 'missing-secret') { + await fse.outputFile(path.join(repoPath, 'env', 'secrets.yaml'), 'secrets:\n - key: SECRET_TOKEN\n'); + vi.stubEnv('SECRET_TOKEN', ''); + } + + const result = await reconcileMcpForConfig(shared(), projectConfig, action === 'remove' ? { removeAll: true } : {}); + if (action === 'update') await reconcileMcpForConfig(shared(), projectConfig); + + expect((await fse.readJson(file)).mcpServers['with-secret']).toEqual(mine); + if (action === 'update') { + expect(result.changes).toContainEqual(expect.objectContaining({ tool: 'copilot', server: 'with-secret', action: 'skipped' })); + expect((await fse.readJson(file))['with-secret']).toEqual(doc['with-secret']); + } + if (action === 'drop' || action === 'remove') expect((await fse.readJson(file))['with-secret']).toBeUndefined(); + }); + it('keeps its line, pull after pull, while Copilot\'s bare entry holds the value beside the mcpServers Claude wrote', async () => { await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); // No longer set: only the entry, not a scan for the value, says what the file holds. diff --git a/src/local-agent.ts b/src/local-agent.ts index 6bdff8b2a..f67775617 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -43,6 +43,7 @@ import { import { readJsonDoc, isTeamaiBareCopy, + ownsJsonMcpEntry, writeJsonDoc, writeCodexAtomic, spliceCodexBlock, @@ -2940,7 +2941,7 @@ async function installMcpServer( if (!doc) { throw new Error(`install_mcp: cannot parse ${targetFile}`); } - if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { + if (doc.servers[slug] !== undefined && !ownsJsonMcpEntry(doc, slug, owned)) { throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); } // The copy a bare install left before another tool added the key would keep the old value beside this one (#882). @@ -3056,8 +3057,9 @@ async function uninstallMcpServer( const doc = await readJsonDoc(targetFile, serverKey, allowBare); // Also a bare entry another tool's mcpServers now sits beside (#882). const bareCopy = doc !== null && isTeamaiBareCopy(doc, slug, owned); - if (doc && (doc.servers[slug] !== undefined || bareCopy)) { - delete doc.servers[slug]; + const ownsEntry = doc !== null && ownsJsonMcpEntry(doc, slug, owned); + if (doc && ((ownsEntry && doc.servers[slug] !== undefined) || bareCopy)) { + if (ownsEntry) delete doc.servers[slug]; if (bareCopy) delete doc.data[slug]; await writeJsonDoc(targetFile, serverKey, doc); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 1df0e4165..a4c53ad41 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1785,6 +1785,11 @@ export function isTeamaiBareCopy(doc: { beside?: Record }, name return bare !== undefined && owned.some((record) => record.name === name && record.bare === true && record.hash === entryHash(bare)); } +/** A bare ownership record cannot claim a same-named entry under mcpServers. */ +export function ownsJsonMcpEntry(doc: Pick, name: string, owned: readonly ManagedMcpRecord[]): boolean { + return owned.some((record) => record.name === name && (doc.bare || record.bare !== true)); +} + // ─── Appliers ──────────────────────────────────────────────── /** Whether it wrote `target`'s file; null when the file does not parse, and so was not read. */ @@ -1806,20 +1811,23 @@ async function applyJson( return null; } - const ownedHash = new Map(owned.map((r) => [r.name, r.hash])); + const ownedHere = owned.filter((record) => doc.bare || record.bare !== true); + const ownedHash = new Map(ownedHere.map((r) => [r.name, r.hash])); let dirty = false; // A kept entry holds the value an earlier pull resolved (desiredMcpForTarget). let holdsResolvedValue = false; for (const [name, { entry, hash, resolvedValue }] of desired) { const existing = doc.servers[name]; - if (existing !== undefined && !ownedNames.has(name) && !options.force) { + if (existing !== undefined && !ownsJsonMcpEntry(doc, name, owned) && !options.force) { changes.push({ tool: target.tool, server: name, action: 'skipped', reason: 'a server with this name already exists and is not managed by teamai', }); + const previous = owned.find((record) => record.name === name); + if (previous) nextRecords.push(previous); continue; } const record: ManagedMcpRecord = { name, hash }; @@ -1841,12 +1849,12 @@ async function applyJson( for (const name of ownedNames) { if (desired.has(name)) continue; const kept = keep.get(name); - if (kept && doc.servers[name] !== undefined) { + if (kept && ((ownsJsonMcpEntry(doc, name, owned) && doc.servers[name] !== undefined) || isTeamaiBareCopy(doc, name, owned))) { nextRecords.push(kept); holdsResolvedValue = true; continue; } - if (doc.servers[name] !== undefined) { + if (ownsJsonMcpEntry(doc, name, owned) && doc.servers[name] !== undefined) { delete doc.servers[name]; dirty = true; } From c0748a9bfcdc2523ff26eb12484a701d7ec17447 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 13:02:28 +0200 Subject: [PATCH 84/85] fix(mcp): require evidence for unmarked Copilot ownership (#882) --- docs/designs/data-directory-layout.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/troubleshooting.md | 2 +- src/__tests__/local-agent-mcp.test.ts | 77 +++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 19 +++++ src/local-agent.ts | 13 ++-- src/mcp-reconcile.ts | 28 +++++-- src/types.ts | 4 +- 9 files changed, 130 insertions(+), 19 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 43fb28510..9a356ee6b 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -439,7 +439,7 @@ every checkout, so that is where they live now: ├── reports-wt/ (the side-branch locks sit beside them) ├── pending-learnings/ pendingLearningsDir → /pending-learnings └── workspaces// - ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot records mark completed bare writes with bare: true + ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882); │ for an HTTP team, the configs the local agent wrote a credential to diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 003477ae5..ac867dbed 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1245,7 +1245,7 @@ precedence. For an existing team that pins run servers in either file; TeamAI does not migrate or delete the old file. Claude Code also reads the root `.mcp.json`, so this file is shared by both tools. -TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. A bare ownership record does not authorize changes to a same-named member entry under `mcpServers`; update skips that collision and removal cleans only the owned bare copy. HTTP local-agent installs check Git protection without changing it, persist ownership, then add the exclusion and file record before writing a credential. A failed ownership write changes neither Git exclusions nor the MCP config. +TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. A bare ownership record does not authorize changes to a same-named member entry under `mcpServers`; update skips that collision and removal cleans only the owned bare copy. An unmarked record can claim a keyed entry only when its hash matches that entry and does not also match the bare entry. Completed keyed writes record `bare: false`; a failed placement-record write leaves ownership unproven. HTTP local-agent installs check Git protection without changing it, persist ownership, then add the exclusion and file record before writing a credential. A failed ownership write changes neither Git exclusions nor the MCP config. Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, remote transports keep `http` or `sse`, and every managed entry gets the required `tools: ["*"]` allowlist. TeamAI honors `COPILOT_HOME`; project configuration uses Copilot CLI's documented `.github/mcp.json` repository location. See [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers). Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. Kiro supports the same `mcpServers` format in its dedicated, mcpServers-only `.kiro/settings/mcp.json` (see [Kiro's MCP configuration docs](https://kiro.dev/docs/mcp/configuration/)). OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b75a2fe30..4ee38a184 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1126,7 +1126,7 @@ CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp) TeamAI 不会迁移或删除旧文件。Claude Code 也读取根目录的 `.mcp.json`, 因此两个工具共享该文件。 -TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。顶层所有权记录不授权修改 `mcpServers` 下的同名成员条目;更新跳过该冲突,移除时只清理受管理的顶层副本。HTTP 本地代理安装先只读检查 Git 保护,再保存所有权,随后添加排除规则和文件记录,最后写入凭据。所有权写入失败不会改变 Git 排除规则或 MCP 配置。 +TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。顶层所有权记录不授权修改 `mcpServers` 下的同名成员条目;更新跳过该冲突,移除时只清理受管理的顶层副本。缺少位置标记的记录只有在哈希匹配嵌套条目且不同时匹配顶层条目时,才能认领嵌套条目。完成的嵌套写入记录 `bare: false`;位置记录写入失败时,所有权仍未得到证明。HTTP 本地代理安装先只读检查 Git 保护,再保存所有权,随后添加排除规则和文件记录,最后写入凭据。所有权写入失败不会改变 Git 排除规则或 MCP 配置。 Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远程传输保留 `http` 或 `sse`,每个 TeamAI 管理的条目都会带上必需的 `tools: ["*"]` 允许列表。TeamAI 遵循 `COPILOT_HOME`,项目配置使用 Copilot CLI 官方文档指定的 `.github/mcp.json` 仓库路径。详见 [GitHub Copilot CLI 添加 MCP Server](https://docs.github.com/zh/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)。Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。Kiro 在专用的、只含 `mcpServers` 的 `.kiro/settings/mcp.json` 中使用同一格式(见 [Kiro MCP 配置文档](https://kiro.dev/docs/mcp/configuration/))。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index f0341a91a..d9f3361c8 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -94,7 +94,7 @@ ever committed with it; then `teamai pull`. Do not run `git rm` or commit for them. For an exclude file that is not writable, one another teamai command held, or a git error, relay the fix the line gives. -For HTTP local-agent MCP installs, a failed ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. A bare ownership record cannot claim a same-named member entry under `mcpServers`: updates skip the collision, and removal leaves that keyed entry alone. +For HTTP local-agent MCP installs, a failed ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. A bare ownership record cannot claim a same-named member entry under `mcpServers`: updates skip the collision, and removal leaves that keyed entry alone. An unmarked Copilot record needs a matching keyed hash that does not also match the bare entry. Completed writes record `bare: true` or `bare: false`; missing placement remains unproven, including after a failed placement-record write. ## Permission / access denied diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index a0829d350..ecdcf4380 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -251,6 +251,83 @@ describe('local-agent: MCP install/uninstall commands', () => { } }); + it.each([ + ['legacy', 'install_mcp', false], ['legacy', 'uninstall_mcp', false], + ['failed placement write', 'install_mcp', false], ['failed placement write', 'uninstall_mcp', false], + ['legacy', 'install_mcp', true], ['legacy', 'uninstall_mcp', true], + ['failed placement write', 'install_mcp', true], ['failed placement write', 'uninstall_mcp', true], + ] as const)('preserves a keyed member entry after an unmarked bare install from %s during %s, identical=%s', async (source, type, identical) => { + const workspacePath = path.join(tmpDir, 'copilot-unmarked-member'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(path.dirname(configFile)); + await fse.writeFile(configFile, ''); + const command = { + id: 9020, type: 'install_mcp', scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', + mcp_config: { transport: 'http', url: 'https://team.example/mcp' }, + }; + const fs = await import('../utils/fs.js'); + const write = fs.writeJsonAtomic; + let writes = 0; + const spy = vi.spyOn(fs, 'writeJsonAtomic').mockImplementation(async (file, ...args) => { + if (source === 'failed placement write' && file.endsWith('/managed-mcp.json') && ++writes === 2) { + throw new Error('simulated placement write failure'); + } + return write(file, ...args); + }); + const installed = await runResponse({ cmds: [command] }, 'copilot'); + spy.mockRestore(); + expect(installed[0].status).toBe(source === 'legacy' ? 'success' : 'failed'); + if (source === 'failed placement write') expect(installed[0].error).toContain('simulated placement write failure'); + const wsDir = path.join(workspacePath, '.teamai', 'workspaces'); + const [id] = await fse.readdir(wsDir); + const manifestFile = path.join(wsDir, id, 'managed-mcp.json'); + const manifest = await fse.readJson(manifestFile); + if (source === 'legacy') { + delete manifest['copilot:project'][0].bare; + await fse.writeJson(manifestFile, manifest); + } + expect((await fse.readJson(manifestFile))['copilot:project'][0].bare).toBeUndefined(); + const doc = await fse.readJson(configFile); + const mine = identical ? doc[COPILOT_SERVER] : { type: 'http', tools: ['*'], url: 'https://member.example/mcp' }; + await fse.writeJson(configFile, { ...doc, mcpServers: { [COPILOT_SERVER]: mine } }); + + const acks = await runResponse({ cmds: [{ ...command, id: 9021, type }] }, 'copilot'); + + expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER]).toEqual(mine); + expect((await fse.readJson(configFile))[COPILOT_SERVER]).toEqual(doc[COPILOT_SERVER]); + expect(acks[0].status).toBe(type === 'install_mcp' ? 'failed' : 'success'); + if (type === 'install_mcp') expect(acks[0].error).toContain('not managed by teamai'); + }); + + it('updates a legacy keyed Copilot entry with matching content and records keyed placement', async () => { + const workspacePath = path.join(tmpDir, 'copilot-legacy-keyed'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(path.dirname(configFile)); + await fse.writeJson(configFile, { mcpServers: {} }); + const command = { + id: 9030, type: 'install_mcp', scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', + mcp_config: { transport: 'http', url: 'https://team.example/mcp' }, + }; + expect((await runResponse({ cmds: [command] }, 'copilot'))[0].status).toBe('success'); + const wsDir = path.join(workspacePath, '.teamai', 'workspaces'); + const [id] = await fse.readdir(wsDir); + const manifestFile = path.join(wsDir, id, 'managed-mcp.json'); + const manifest = await fse.readJson(manifestFile); + expect(manifest['copilot:project'][0].bare).toBe(false); + delete manifest['copilot:project'][0].bare; + await fse.writeJson(manifestFile, manifest); + + const updated = await runResponse({ cmds: [{ ...command, id: 9031, mcp_config: { transport: 'http', url: 'https://team.example/updated' } }] }, 'copilot'); + + expect(updated[0].status).toBe('success'); + expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER].url).toBe('https://team.example/updated'); + expect((await fse.readJson(manifestFile))['copilot:project'][0].bare).toBe(false); + expect((await runResponse({ cmds: [{ ...command, id: 9032, type: 'uninstall_mcp' }] }, 'copilot'))[0].status).toBe('success'); + expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER]).toBeUndefined(); + }); + it('rejects an unmanaged collision in a bare Copilot project map', async () => { const workspacePath = path.join(tmpDir, 'copilot-collision-project'); const configFile = path.join(workspacePath, '.github', 'mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 8a20ffacb..cbf8b5d91 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1187,6 +1187,25 @@ servers: if (action === 'drop' || action === 'remove') expect((await fse.readJson(file))['with-secret']).toBeUndefined(); }); + it.each([['update', false], ['remove', false], ['update', true], ['remove', true]] as const)('preserves a keyed member entry with an unmarked bare record during %s, identical=%s', async (action, identical) => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile); + delete manifest['copilot:project'][0].bare; + await fse.writeJson(manifestFile, manifest); + const file = path.join(projectRoot, '.mcp.json'); + const doc = await fse.readJson(file); + const mine = identical ? doc['with-secret'] : { type: 'http', tools: ['*'], url: 'https://member.example/mcp' }; + await fse.writeJson(file, { ...doc, mcpServers: { 'with-secret': mine } }); + if (action === 'update') await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'new-team-value')} tools: [copilot]\n`); + + const result = await reconcileMcpForConfig(shared(), projectConfig, action === 'remove' ? { removeAll: true } : {}); + + expect((await fse.readJson(file)).mcpServers['with-secret']).toEqual(mine); + expect((await fse.readJson(file))['with-secret']).toEqual(doc['with-secret']); + if (action === 'update') expect(result.changes).toContainEqual(expect.objectContaining({ tool: 'copilot', server: 'with-secret', action: 'skipped' })); + }); + it('keeps its line, pull after pull, while Copilot\'s bare entry holds the value beside the mcpServers Claude wrote', async () => { await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); // No longer set: only the entry, not a scan for the value, says what the file holds. diff --git a/src/local-agent.ts b/src/local-agent.ts index f67775617..aa5c17f54 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2853,7 +2853,7 @@ function updateManifestRecord( ): void { const records = manifest[key] ?? []; const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); - const record: ManagedMcpRecord = { name, hash, ...resolved === undefined ? {} : { resolved }, ...bare ? { bare: true } : {} }; + const record: ManagedMcpRecord = { name, hash, ...resolved === undefined ? {} : { resolved }, ...bare === undefined ? {} : { bare } }; if (idx >= 0) { records[idx] = record; } else { @@ -2941,7 +2941,7 @@ async function installMcpServer( if (!doc) { throw new Error(`install_mcp: cannot parse ${targetFile}`); } - if (doc.servers[slug] !== undefined && !ownsJsonMcpEntry(doc, slug, owned)) { + if (doc.servers[slug] !== undefined && !ownsJsonMcpEntry(doc, slug, owned, allowBare)) { throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); } // The copy a bare install left before another tool added the key would keep the old value beside this one (#882). @@ -2949,15 +2949,16 @@ async function installMcpServer( const bareCopy = isTeamaiBareCopy(doc, slug, owned); // Check Git without changing it until ownership is persisted. Recheck protection before writing the credential (#882). const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry, true); - updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined); + const priorPlacement = owned.find((record) => record.name === slug)?.bare; + updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, priorPlacement === doc.bare ? priorPlacement : undefined); await writeJsonAtomic(manifestPath, manifest); if (credential) await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); if (bareCopy) delete doc.data[slug]; doc.servers[slug] = entry; await writeJsonDoc(targetFile, serverKey, doc); - if (doc.bare) { + if (allowBare) { // Placement is evidence of a completed write, not just an attempted install. - updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, true); + updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, doc.bare); await writeJsonAtomic(manifestPath, manifest); } } @@ -3057,7 +3058,7 @@ async function uninstallMcpServer( const doc = await readJsonDoc(targetFile, serverKey, allowBare); // Also a bare entry another tool's mcpServers now sits beside (#882). const bareCopy = doc !== null && isTeamaiBareCopy(doc, slug, owned); - const ownsEntry = doc !== null && ownsJsonMcpEntry(doc, slug, owned); + const ownsEntry = doc !== null && ownsJsonMcpEntry(doc, slug, owned, allowBare); if (doc && ((ownsEntry && doc.servers[slug] !== undefined) || bareCopy)) { if (ownsEntry) delete doc.servers[slug]; if (bareCopy) delete doc.data[slug]; diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index a4c53ad41..2280e0459 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -1785,9 +1785,22 @@ export function isTeamaiBareCopy(doc: { beside?: Record }, name return bare !== undefined && owned.some((record) => record.name === name && record.bare === true && record.hash === entryHash(bare)); } -/** A bare ownership record cannot claim a same-named entry under mcpServers. */ -export function ownsJsonMcpEntry(doc: Pick, name: string, owned: readonly ManagedMcpRecord[]): boolean { - return owned.some((record) => record.name === name && (doc.bare || record.bare !== true)); +/** Copilot project ownership without placement needs a matching, unambiguous entry. */ +export function ownsJsonMcpEntry( + doc: Pick, + name: string, + owned: readonly ManagedMcpRecord[], + allowBare: boolean, +): boolean { + return owned.some((record) => { + if (record.name !== name) return false; + if (!allowBare) return record.bare !== true; + if (record.bare !== undefined) return record.bare === doc.bare; + const entry = doc.servers[name]; + const beside = doc.beside?.[name]; + return entry !== undefined && record.hash === entryHash(entry) + && (beside === undefined || record.hash !== entryHash(beside)); + }); } // ─── Appliers ──────────────────────────────────────────────── @@ -1811,7 +1824,7 @@ async function applyJson( return null; } - const ownedHere = owned.filter((record) => doc.bare || record.bare !== true); + const ownedHere = owned.filter((record) => ownsJsonMcpEntry(doc, record.name, [record], allowBare)); const ownedHash = new Map(ownedHere.map((r) => [r.name, r.hash])); let dirty = false; // A kept entry holds the value an earlier pull resolved (desiredMcpForTarget). @@ -1819,7 +1832,7 @@ async function applyJson( for (const [name, { entry, hash, resolvedValue }] of desired) { const existing = doc.servers[name]; - if (existing !== undefined && !ownsJsonMcpEntry(doc, name, owned) && !options.force) { + if (existing !== undefined && !ownsJsonMcpEntry(doc, name, owned, allowBare) && !options.force) { changes.push({ tool: target.tool, server: name, @@ -1831,6 +1844,7 @@ async function applyJson( continue; } const record: ManagedMcpRecord = { name, hash }; + if (allowBare && !doc.bare) record.bare = false; if (doc.bare && owned.some((r) => r.name === name && r.bare === true)) record.bare = true; nextRecords.push(record); holdsResolvedValue ||= resolvedValue; @@ -1849,12 +1863,12 @@ async function applyJson( for (const name of ownedNames) { if (desired.has(name)) continue; const kept = keep.get(name); - if (kept && ((ownsJsonMcpEntry(doc, name, owned) && doc.servers[name] !== undefined) || isTeamaiBareCopy(doc, name, owned))) { + if (kept && ((ownsJsonMcpEntry(doc, name, owned, allowBare) && doc.servers[name] !== undefined) || isTeamaiBareCopy(doc, name, owned))) { nextRecords.push(kept); holdsResolvedValue = true; continue; } - if (ownsJsonMcpEntry(doc, name, owned) && doc.servers[name] !== undefined) { + if (ownsJsonMcpEntry(doc, name, owned, allowBare) && doc.servers[name] !== undefined) { delete doc.servers[name]; dirty = true; } diff --git a/src/types.ts b/src/types.ts index 5cbe5cfa7..8d5f49f3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -904,8 +904,8 @@ export interface ManagedMcpRecord { * (#882). Absent in records an older teamai wrote. */ resolved?: boolean; - /** TeamAI wrote this entry in Copilot's bare project map. Absent means unproven. */ - bare?: true; + /** Completed Copilot project write: true for bare, false for keyed; absent means unproven. */ + bare?: boolean; /** * Project scope: this record was rebuilt after it was lost, or written by a * pull that found no managed-mcp.json, and the other servers in its file From d548eaa2d11d1db13574f579222d4cbcc8be8b9d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 30 Sep 2026 13:52:14 +0200 Subject: [PATCH 85/85] fix(mcp): preserve ownership across failed config updates (#882) --- docs/designs/data-directory-layout.md | 9 + docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/core/references/troubleshooting.md | 2 +- src/__tests__/local-agent-mcp.test.ts | 198 ++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 77 ++++++- src/local-agent.ts | 76 +++++-- src/mcp-reconcile.ts | 53 ++++- 8 files changed, 393 insertions(+), 30 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 9a356ee6b..093ece986 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -447,6 +447,15 @@ every checkout, so that is where they live now: /.teamai/ one per checkout: committed knowledge, knowledge-wt/ ``` +MCP configs and ownership must describe the same completed writes. Existing +JSON local-agent installs keep the old record until the config write succeeds; +uninstall keeps it until the entry is removed. A later manifest-write failure +restores the previous config. Reconcile keeps one snapshot per config before +any tool writes it and restores those snapshots if saving ownership or a later +config write fails. File records added by that failed run are cleaned up before +Git protection is checked against the restored configs. If restoration also +fails, the command reports both failures and keeps credential files excluded. + `git worktree add` takes a path outside the repo, and the owning repo is still the business repo, whose refs every checkout shares. The search index is keyed per checkout, like managed MCP, because each checkout indexes its own branch's diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ac867dbed..d2a3ad433 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1245,7 +1245,9 @@ precedence. For an existing team that pins run servers in either file; TeamAI does not migrate or delete the old file. Claude Code also reads the root `.mcp.json`, so this file is shared by both tools. -TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. A bare ownership record does not authorize changes to a same-named member entry under `mcpServers`; update skips that collision and removal cleans only the owned bare copy. An unmarked record can claim a keyed entry only when its hash matches that entry and does not also match the bare entry. Completed keyed writes record `bare: false`; a failed placement-record write leaves ownership unproven. HTTP local-agent installs check Git protection without changing it, persist ownership, then add the exclusion and file record before writing a credential. A failed ownership write changes neither Git exclusions nor the MCP config. +TeamAI removes a bare Copilot entry beside `mcpServers` only when its ownership record proves a completed bare write and the entry still matches that write. Older records without placement evidence leave the bare entry alone, even if it matches the team definition. A bare ownership record does not authorize changes to a same-named member entry under `mcpServers`; update skips that collision and removal cleans only the owned bare copy. An unmarked record can claim a keyed entry only when its hash matches that entry and does not also match the bare entry. Completed keyed writes record `bare: false`; a failed placement-record write leaves ownership unproven. New HTTP local-agent installs check Git protection without changing it, persist provisional ownership, then add the exclusion and file record before writing a credential. A failed initial ownership write changes neither Git exclusions nor the MCP config. + +An HTTP local-agent update keeps the existing JSON MCP ownership record until the config write succeeds. If saving the new record then fails, it restores the previous config. `uninstall_mcp` removes the entry before dropping its ownership record; a failed config write or an unreadable config keeps that record for a retry, and a failed manifest write restores the entry. A failed MCP reconcile restores each config it wrote before saving ownership, including a file shared by multiple tools. A restoration failure reports both errors and the affected files: repair the config and ownership record before retrying. Git protection remains while a credential is still present. Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, remote transports keep `http` or `sse`, and every managed entry gets the required `tools: ["*"]` allowlist. TeamAI honors `COPILOT_HOME`; project configuration uses Copilot CLI's documented `.github/mcp.json` repository location. See [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers). Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. Kiro supports the same `mcpServers` format in its dedicated, mcpServers-only `.kiro/settings/mcp.json` (see [Kiro's MCP configuration docs](https://kiro.dev/docs/mcp/configuration/)). OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 4ee38a184..1307e1408 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1126,7 +1126,9 @@ CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp) TeamAI 不会迁移或删除旧文件。Claude Code 也读取根目录的 `.mcp.json`, 因此两个工具共享该文件。 -TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。顶层所有权记录不授权修改 `mcpServers` 下的同名成员条目;更新跳过该冲突,移除时只清理受管理的顶层副本。缺少位置标记的记录只有在哈希匹配嵌套条目且不同时匹配顶层条目时,才能认领嵌套条目。完成的嵌套写入记录 `bare: false`;位置记录写入失败时,所有权仍未得到证明。HTTP 本地代理安装先只读检查 Git 保护,再保存所有权,随后添加排除规则和文件记录,最后写入凭据。所有权写入失败不会改变 Git 排除规则或 MCP 配置。 +TeamAI 仅在所有权记录证明已完成顶层写入且内容仍匹配时,才删除 `mcpServers` 旁的 Copilot 顶层条目。旧记录缺少位置证据时,即使内容与团队定义相同,也保留顶层条目。顶层所有权记录不授权修改 `mcpServers` 下的同名成员条目;更新跳过该冲突,移除时只清理受管理的顶层副本。缺少位置标记的记录只有在哈希匹配嵌套条目且不同时匹配顶层条目时,才能认领嵌套条目。完成的嵌套写入记录 `bare: false`;位置记录写入失败时,所有权仍未得到证明。HTTP 本地代理首次安装先只读检查 Git 保护,再保存临时所有权记录,随后添加排除规则和文件记录,最后写入凭据。初始所有权记录写入失败不会改变 Git 排除规则或 MCP 配置。 + +HTTP local-agent 更新 JSON MCP 配置时,先保留原有 ownership 记录,配置写入成功后才更新记录;如果随后保存记录失败,会恢复原配置。`uninstall_mcp` 先删除配置中的条目,再移除 ownership 记录:配置写入失败或无法读取时保留记录以便重试,manifest 写入失败时恢复条目。MCP reconcile 在保存 ownership 前失败时,会恢复本次已写入的所有配置,包括多个工具共用的文件。恢复本身也失败时,错误会同时说明两次失败及受影响的文件;修复配置与 ownership 记录后再重试。只要凭据仍在文件中,就继续保留 Git 排除保护。 Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远程传输保留 `http` 或 `sse`,每个 TeamAI 管理的条目都会带上必需的 `tools: ["*"]` 允许列表。TeamAI 遵循 `COPILOT_HOME`,项目配置使用 Copilot CLI 官方文档指定的 `.github/mcp.json` 仓库路径。详见 [GitHub Copilot CLI 添加 MCP Server](https://docs.github.com/zh/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)。Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。Kiro 在专用的、只含 `mcpServers` 的 `.kiro/settings/mcp.json` 中使用同一格式(见 [Kiro MCP 配置文档](https://kiro.dev/docs/mcp/configuration/))。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index d9f3361c8..790b52f1e 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -94,7 +94,7 @@ ever committed with it; then `teamai pull`. Do not run `git rm` or commit for them. For an exclude file that is not writable, one another teamai command held, or a git error, relay the fix the line gives. -For HTTP local-agent MCP installs, a failed ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. A bare ownership record cannot claim a same-named member entry under `mcpServers`: updates skip the collision, and removal leaves that keyed entry alone. An unmarked Copilot record needs a matching keyed hash that does not also match the bare entry. Completed writes record `bare: true` or `bare: false`; missing placement remains unproven, including after a failed placement-record write. +For new HTTP local-agent MCP installs, a failed initial ownership-manifest write leaves the MCP config and Git exclusions unchanged. Retry the install after fixing the manifest write error. A bare Copilot entry beside `mcpServers` is removed only with a matching ownership record proving a completed bare write. Older records without that evidence preserve the bare entry. A bare ownership record cannot claim a same-named member entry under `mcpServers`: updates skip the collision, and removal leaves that keyed entry alone. An unmarked Copilot record needs a matching keyed hash that does not also match the bare entry. Completed writes record `bare: true` or `bare: false`; missing placement remains unproven, including after a failed placement-record write. Existing JSON MCP updates keep the old ownership until the config write completes; a later manifest failure restores the config. `uninstall_mcp` keeps ownership if reading or writing the config fails, and restores the entry if removing its manifest record fails. MCP reconcile also restores all configs written before an ownership-save failure. If restoration fails too, repair the named configs and ownership records before retrying; the error reports both failures, and configs still carrying credentials stay excluded from Git. ## Permission / access denied diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index ecdcf4380..86a910699 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -328,6 +328,204 @@ describe('local-agent: MCP install/uninstall commands', () => { expect((await fse.readJson(configFile)).mcpServers[COPILOT_SERVER]).toBeUndefined(); }); + it.each((['proven bare', 'proven bare migration', 'legacy bare', 'proven keyed', 'legacy keyed'] as const) + .flatMap((source) => (['config', 'manifest'] as const) + .flatMap((failure) => (['retry', 'uninstall'] as const).map((next) => [source, failure, next] as const))))( + 'preserves ownership for %s after a %s write fails, then allows %s', async (source, failure, next) => { + const workspacePath = path.join(tmpDir, 'copilot-failed-update'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(path.dirname(configFile)); + execFileSync('git', ['init', '-q'], { cwd: workspacePath }); + await fse.writeFile(configFile, source.includes('bare') ? '' : '{"mcpServers":{}}'); + const command = { + id: 9040, type: 'install_mcp', scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', + mcp_config: { transport: 'http', url: 'https://team.example/mcp', headers: { Authorization: 'Bearer fixture-old' } }, + }; + expect((await runResponse({ cmds: [command] }, 'copilot'))[0].status).toBe('success'); + const wsDir = path.join(workspacePath, '.teamai', 'workspaces'); + const [id] = await fse.readdir(wsDir); + const manifestFile = path.join(wsDir, id, 'managed-mcp.json'); + const before = await fse.readJson(manifestFile); + if (source.startsWith('legacy')) { + delete before['copilot:project'][0].bare; + await fse.writeJson(manifestFile, before); + } + if (source === 'proven bare migration') { + await fse.writeJson(configFile, { ...(await fse.readJson(configFile)), mcpServers: { mine: { command: 'member-server' } } }); + } + const original = await fse.readJson(configFile); + const replacement = { ...command, id: 9041, mcp_config: { transport: 'stdio', command: 'replacement-server' } }; + const fs = await import('../utils/fs.js'); + const write = fs.writeJsonAtomic; + let configWritten = false; + const spy = vi.spyOn(fs, 'writeJsonAtomic').mockImplementation(async (file, ...args) => { + if (failure === 'config' && file.endsWith('/.github/mcp.json')) throw new Error('simulated MCP config write failure'); + if (failure === 'manifest' && file.endsWith('/managed-mcp.json') && configWritten) throw new Error('simulated manifest write failure'); + await write(file, ...args); + if (file.endsWith('/.github/mcp.json')) configWritten = true; + }); + + const failed = await runResponse({ cmds: [replacement] }, 'copilot'); + spy.mockRestore(); + const afterFailure = await fse.readJson(manifestFile); + expect(failed[0].status).toBe('failed'); + expect(failed[0].error).toContain(failure === 'config' ? 'simulated MCP config write failure' : 'simulated manifest write failure'); + expect(await fse.readJson(configFile)).toEqual(original); + if (source === 'proven bare') { + await fse.writeJson(configFile, { ...original, mcpServers: { mine: { command: 'member-server' } } }); + } + + const resumed = await runResponse({ cmds: [{ ...replacement, id: 9042, type: next === 'retry' ? 'install_mcp' : 'uninstall_mcp' }] }, 'copilot'); + + expect(resumed[0].status).toBe('success'); + const after = await fse.readJson(configFile); + if (next === 'uninstall') { + expect(after[COPILOT_SERVER]).toBeUndefined(); + expect(after.mcpServers?.[COPILOT_SERVER]).toBeUndefined(); + } else { + expect(JSON.stringify(after)).not.toContain('fixture-old'); + expect(JSON.stringify(after)).toContain('replacement-server'); + if (source.startsWith('proven bare')) expect(after[COPILOT_SERVER]).toBeUndefined(); + } + if (source.startsWith('proven bare')) expect(after.mcpServers.mine).toEqual({ command: 'member-server' }); + expect(afterFailure).toEqual(before); + }); + + it.each((['copilot bare', 'copilot keyed', 'codebuddy keyed', 'codex user'] as const) + .flatMap((source) => (['config', 'manifest'] as const).map((failure) => [source, failure] as const)))( + 'allows retrying uninstall of %s after a %s write fails', async (source, failure) => { + const tool = source.split(' ')[0]; + const workspacePath = path.join(tmpDir, 'failed-uninstall'); + const configFile = source === 'codex user' ? path.join(tmpDir, '.codex', 'config.toml') + : path.join(workspacePath, tool === 'copilot' ? '.github/mcp.json' : '.mcp.json'); + await fse.ensureDir(path.dirname(configFile)); + if (tool !== 'codex') await fse.writeFile(configFile, source === 'copilot bare' ? '' : '{"mcpServers":{}}'); + const command = { + id: 9050, type: 'install_mcp', scope: tool === 'codex' ? 'user' : 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', mcp_config: { transport: 'stdio', command: 'team-server' }, + }; + expect((await runResponse({ cmds: [command] }, tool))[0].status).toBe('success'); + const wsDir = path.join(workspacePath, '.teamai', 'workspaces'); + const manifestFile = tool === 'codex' ? path.join(tmpDir, '.teamai', 'managed-mcp.json') + : path.join(wsDir, (await fse.readdir(wsDir))[0], 'managed-mcp.json'); + const before = await fse.readJson(manifestFile); + const original = await fse.readFile(configFile, 'utf-8'); + const fs = await import('../utils/fs.js'); + const write = fs.writeJsonAtomic; + const spy = vi.spyOn(fs, 'writeJsonAtomic').mockImplementation(async (file, ...args) => { + if ((failure === 'config' && file.endsWith(tool === 'copilot' ? '/.github/mcp.json' : '/.mcp.json')) || (failure === 'manifest' && file.endsWith('/managed-mcp.json'))) { + throw new Error(`simulated ${failure} write failure`); + } + return write(file, ...args); + }); + const reconcile = await import('../mcp-reconcile.js'); + const codexWrite = reconcile.writeCodexAtomic; + const codexSpy = vi.spyOn(reconcile, 'writeCodexAtomic').mockImplementation(async (...args) => { + if (failure === 'config') throw new Error('simulated config write failure'); + return codexWrite(...args); + }); + const removal = { ...command, id: 9051, type: 'uninstall_mcp' }; + + const failed = await runResponse({ cmds: [removal] }, tool); + spy.mockRestore(); + codexSpy.mockRestore(); + + expect(failed[0].status).toBe('failed'); + expect(failed[0].error).toContain(`simulated ${failure} write failure`); + expect(await fse.readJson(manifestFile)).toEqual(before); + expect(await fse.readFile(configFile, 'utf-8')).toBe(original); + expect((await runResponse({ cmds: [{ ...removal, id: 9052 }] }, tool))[0].status).toBe('success'); + expect(await fse.readFile(configFile, 'utf-8')).not.toContain('team-server'); + }); + + it('keeps ownership when uninstall cannot parse the config, then removes the repaired entry', async () => { + const workspacePath = path.join(tmpDir, 'malformed-uninstall'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(workspacePath); + const command = { + id: 9060, type: 'install_mcp', scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', mcp_config: { transport: 'stdio', command: 'team-server' }, + }; + expect((await runResponse({ cmds: [command] }, 'copilot'))[0].status).toBe('success'); + const wsDir = path.join(workspacePath, '.teamai', 'workspaces'); + const manifestFile = path.join(wsDir, (await fse.readdir(wsDir))[0], 'managed-mcp.json'); + const before = await fse.readJson(manifestFile); + const original = await fse.readFile(configFile, 'utf-8'); + await fse.writeFile(configFile, '{invalid config'); + const removal = { ...command, id: 9061, type: 'uninstall_mcp' }; + + const failed = await runResponse({ cmds: [removal] }, 'copilot'); + + expect(failed[0].status).toBe('failed'); + expect(failed[0].error).toContain('cannot parse'); + expect(await fse.readJson(manifestFile)).toEqual(before); + expect(await fse.readFile(configFile, 'utf-8')).toBe('{invalid config'); + await fse.writeFile(configFile, original); + expect((await runResponse({ cmds: [{ ...removal, id: 9062 }] }, 'copilot'))[0].status).toBe('success'); + expect(await fse.readFile(configFile, 'utf-8')).not.toContain('team-server'); + }); + + it.each(['install_mcp', 'uninstall_mcp'] as const)( + 'keeps Codex config and ownership when %s cannot read the config', async (type) => { + const configFile = path.join(tmpDir, '.codex', 'config.toml'); + const command = { + id: 9070, type: 'install_mcp', scope: 'user', slug: COPILOT_SERVER, version: '1.0.0', + mcp_config: { transport: 'stdio', command: 'team-server' }, + }; + expect((await runResponse({ cmds: [command] }, 'codex'))[0].status).toBe('success'); + const manifestFile = path.join(tmpDir, '.teamai', 'managed-mcp.json'); + const before = await fse.readJson(manifestFile); + const original = await fse.readFile(configFile, 'utf-8'); + const read = fse.readFile; + const spy = vi.spyOn(fse, 'readFile').mockImplementation((file, ...args) => { + if (String(file).endsWith('/.codex/config.toml')) return Promise.reject(new Error('simulated config read failure')); + return read(file, ...args); + }); + + const failed = await runResponse({ cmds: [{ ...command, id: 9071, type }] }, 'codex'); + spy.mockRestore(); + + expect(failed[0].status).toBe('failed'); + expect(failed[0].error).toContain('simulated config read failure'); + expect(await fse.readJson(manifestFile)).toEqual(before); + expect(await fse.readFile(configFile, 'utf-8')).toBe(original); + expect((await runResponse({ cmds: [{ ...command, id: 9072, type }] }, 'codex'))[0].status).toBe('success'); + }); + + it.each(['install_mcp', 'uninstall_mcp'] as const)( + 'reports both failures when %s cannot restore a config after its manifest write fails', async (type) => { + const workspacePath = path.join(tmpDir, 'failed-restoration'); + const configFile = path.join(workspacePath, '.github', 'mcp.json'); + await fse.ensureDir(workspacePath); + const command = { + id: 9080, type: 'install_mcp', scope: 'workspace', workspace_path: workspacePath, + slug: COPILOT_SERVER, version: '1.0.0', mcp_config: { transport: 'stdio', command: 'team-server' }, + }; + expect((await runResponse({ cmds: [command] }, 'copilot'))[0].status).toBe('success'); + const original = await fse.readJson(configFile); + const fs = await import('../utils/fs.js'); + const write = fs.writeJsonAtomic; + let configWritten = false; + const spy = vi.spyOn(fs, 'writeJsonAtomic').mockImplementation(async (file, ...args) => { + if (file.endsWith('/managed-mcp.json')) throw new Error('simulated manifest write failure'); + if (file.endsWith('/.github/mcp.json') && configWritten) throw new Error('simulated restoration failure'); + await write(file, ...args); + if (file.endsWith('/.github/mcp.json')) configWritten = true; + }); + const retry = { ...command, id: 9081, type, mcp_config: { transport: 'stdio', command: 'replacement-server' } }; + + const failed = await runResponse({ cmds: [retry] }, 'copilot'); + spy.mockRestore(); + + expect(failed[0].status).toBe('failed'); + expect(failed[0].error).toContain('simulated manifest write failure'); + expect(failed[0].error).toContain('simulated restoration failure'); + expect(failed[0].error).toContain('The config may not match'); + await fse.writeJson(configFile, original); + expect((await runResponse({ cmds: [{ ...retry, id: 9082 }] }, 'copilot'))[0].status).toBe('success'); + }); + it('rejects an unmanaged collision in a bare Copilot project map', async () => { const workspacePath = path.join(tmpDir, 'copilot-collision-project'); const configFile = path.join(workspacePath, '.github', 'mcp.json'); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index cbf8b5d91..19145eb61 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -1206,6 +1206,62 @@ servers: if (action === 'update') expect(result.changes).toContainEqual(expect.objectContaining({ tool: 'copilot', server: 'with-secret', action: 'skipped' })); }); + it.each(['legacy bare', 'legacy keyed', 'proven keyed', 'bare migration'] as const)( + 'restores %s after a manifest write fails so update and removal can be retried', async (source) => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const manifest = await fse.readJson(manifestFile); + const file = path.join(projectRoot, '.mcp.json'); + const doc = await fse.readJson(file); + if (source.endsWith('keyed')) { + await fse.writeJson(file, { mcpServers: doc }); + manifest['copilot:project'][0].bare = false; + } + if (source.startsWith('legacy')) delete manifest['copilot:project'][0].bare; + if (source === 'bare migration') await fse.writeJson(file, { ...doc, mcpServers: { mine: { command: 'member-server' } } }); + await fse.writeJson(manifestFile, manifest); + const original = await fse.readJson(file); + await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'replacement')} tools: [copilot]\n`); + beforeJsonWrite.run = async (target) => { + if (target.endsWith('/managed-mcp.json')) throw new Error('simulated manifest write failure'); + }; + + await expect(reconcileMcpForConfig(shared(), projectConfig)).rejects.toThrow('simulated manifest write failure'); + beforeJsonWrite.run = null; + + expect(await fse.readJson(manifestFile)).toEqual(manifest); + expect(await fse.readJson(file)).toEqual(original); + expect(git(projectRoot, 'status', '--porcelain', '--untracked-files=all', '--', '.mcp.json')).toBe(''); + await reconcileMcpForConfig(shared(), projectConfig); + expect(await fse.readFile(file, 'utf-8')).not.toContain('super-secret-value'); + await reconcileMcpForConfig(shared(), projectConfig, { removeAll: true }); + const removed = await fse.readJson(file); + expect(removed['with-secret']).toBeUndefined(); + expect(removed.mcpServers?.['with-secret']).toBeUndefined(); + if (source === 'bare migration') expect(removed.mcpServers.mine).toEqual({ command: 'member-server' }); + }); + + it('restores the first write when a second tool fails on the same config', async () => { + const { getDataHome, managedMcpManifestPath } = await import('../types.js'); + const manifestFile = managedMcpManifestPath(getDataHome(projectConfig), projectRoot); + const file = path.join(projectRoot, '.mcp.json'); + const original = await fse.readJson(file); + const manifest = await fse.readJson(manifestFile); + await writeMcpYaml(`${withSecret.replace('${SECRET_TOKEN}', 'replacement')} tools: [copilot]\n${open.replace('servers:\n', '')}`); + let writes = 0; + beforeJsonWrite.run = async (target) => { + if (target === file && ++writes === 2) throw new Error('simulated second config write failure'); + }; + + await expect(reconcileMcpForConfig(shared(), projectConfig)).rejects.toThrow('simulated second config write failure'); + beforeJsonWrite.run = null; + + expect(await fse.readJson(file)).toEqual(original); + expect(await fse.readJson(manifestFile)).toEqual(manifest); + await reconcileMcpForConfig(shared(), projectConfig); + expect(await fse.readFile(file, 'utf-8')).not.toContain('super-secret-value'); + }); + it('keeps its line, pull after pull, while Copilot\'s bare entry holds the value beside the mcpServers Claude wrote', async () => { await writeMcpYaml(`${withSecret} tools: [copilot]\n${open.replace('servers:\n', '')}`); // No longer set: only the entry, not a scan for the value, says what the file holds. @@ -2204,7 +2260,7 @@ servers: expect(vi.mocked(log.debug).mock.calls.flat().join('\n')).toMatch(/\/\.mcp\.json/); }); - it('but one this pull listed stays when it wrote the value and then failed to record it', async () => { + it.each([false, true])('handles a failed ownership write after adding a credential, restoration fails=%s', async (restoreFails) => { // Shorter than eight characters: no scan of the file can find it again. vi.stubEnv('SECRET_TOKEN', 'short'); await writeMcpYaml(withSecret); @@ -2212,10 +2268,23 @@ servers: if (path.basename(file) === 'managed-mcp.json') throw new Error('disk full'); }; - await expect(reconcileMcpForConfig(teamConfig, claudeOnly())).rejects.toThrow('disk full'); + const rm = fs.promises.rm; + const spy = vi.spyOn(fs.promises, 'rm').mockImplementation(async (file, ...args) => { + if (restoreFails && String(file) === mcpJson()) throw new Error('simulated restoration failure'); + return rm(file, ...args); + }); + await expect(reconcileMcpForConfig(teamConfig, claudeOnly())).rejects.toThrow(restoreFails ? /restoring configs failed.*simulated restoration failure/ : 'disk full'); + spy.mockRestore(); - expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('Bearer short'); - expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + if (restoreFails) { + expect(await fse.readFile(mcpJson(), 'utf-8')).toContain('Bearer short'); + expect(await excludeOf(projectRoot)).toMatch(/^\/\.mcp\.json$/m); + } else { + expect(await fse.pathExists(mcpJson())).toBe(false); + expect(await excludeOf(projectRoot)).not.toMatch(/^\/\.mcp\.json$/m); + const { readResolvedMcpFiles } = await import('../mcp-resolved-files.js'); + expect((await readResolvedMcpFiles(claudeOnly())).files[mcpJson()]).toBeUndefined(); + } }); it('but one an earlier pull listed stays while the config cannot be proven clean', async () => { diff --git a/src/local-agent.ts b/src/local-agent.ts index aa5c17f54..5729273af 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -13,6 +13,7 @@ import { listFilesRecursive, pathExists, readFileSafe, + readFileIfExists, readJson, remove, writeFile, @@ -2923,7 +2924,7 @@ async function installMcpServer( if (format === 'codex') { const block = renderCodexBlock(def); const hash = entryHash(block); - let source = (await readFileSafe(targetFile)) ?? ''; + let source = (await readFileIfExists(targetFile)) ?? ''; const present = new Set(codexServerNames(source)); if (present.has(slug) && !ownedNames.has(slug)) { throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); @@ -2949,17 +2950,37 @@ async function installMcpServer( const bareCopy = isTeamaiBareCopy(doc, slug, owned); // Check Git without changing it until ownership is persisted. Recheck protection before writing the credential (#882). const credential = projectScope && await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry, true); - const priorPlacement = owned.find((record) => record.name === slug)?.bare; - updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, priorPlacement === doc.bare ? priorPlacement : undefined); - await writeJsonAtomic(manifestPath, manifest); + const previousRecord = owned.find((record) => record.name === slug); + const previousData = previousRecord ? structuredClone(doc.data) : undefined; + // Existing ownership stays valid until the config write completes. New installs + // still persist a provisional record before adding a Git exclusion (#882). + if (!previousRecord) { + updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined); + await writeJsonAtomic(manifestPath, manifest); + } if (credential) await keepCredentialOutOfGit({ ...localConfig, dataHome }, tool, slug, targetFile, entry); if (bareCopy) delete doc.data[slug]; doc.servers[slug] = entry; await writeJsonDoc(targetFile, serverKey, doc); - if (allowBare) { + if (allowBare || previousRecord) { // Placement is evidence of a completed write, not just an attempted install. - updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, doc.bare); - await writeJsonAtomic(manifestPath, manifest); + updateManifestRecord(manifest, manifestKey, slug, hash, projectScope ? credential : undefined, allowBare ? doc.bare : undefined); + try { + await writeJsonAtomic(manifestPath, manifest); + } catch (error) { + if (previousData) { + try { + await writeJsonAtomic(targetFile, previousData); + } catch (restoreError) { + throw new Error( + `install_mcp: ownership write failed (${error instanceof Error ? error.message : String(error)}), and restoring ${targetFile} failed ` + + `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}). The config may not match ${manifestPath}. Repair the config and ownership record after fixing both write errors, then install the server again.`, + { cause: error }, + ); + } + } + throw error; + } } } log.debug(`local-agent: installed MCP server "${slug}" for ${tool} (scope=${scope})`); @@ -3044,26 +3065,47 @@ async function uninstallMcpServer( if (!ownedNames.has(slug)) return; - manifest[manifestKey] = owned.filter((r: ManagedMcpRecord) => r.name !== slug); - if ((manifest[manifestKey] as ManagedMcpRecord[]).length === 0) delete manifest[manifestKey]; - await writeJsonAtomic(manifestPath, manifest); - + let restoreConfig: (() => Promise) | undefined; if (format === 'codex') { - let source = (await readFileSafe(targetFile)) ?? ''; - source = spliceCodexBlock(source, slug, null); - await writeCodexAtomic(targetFile, source); + const source = (await readFileIfExists(targetFile)) ?? ''; + const next = spliceCodexBlock(source, slug, null); + if (next !== source) { + await writeCodexAtomic(targetFile, next); + restoreConfig = () => writeCodexAtomic(targetFile, source); + } } else { const serverKey = MCP_SERVER_KEY[format]; const allowBare = format === 'copilot' && projectScope; const doc = await readJsonDoc(targetFile, serverKey, allowBare); + if (!doc) throw new Error(`uninstall_mcp: cannot parse ${targetFile}. Ownership was kept; repair the config and uninstall the server again.`); // Also a bare entry another tool's mcpServers now sits beside (#882). - const bareCopy = doc !== null && isTeamaiBareCopy(doc, slug, owned); - const ownsEntry = doc !== null && ownsJsonMcpEntry(doc, slug, owned, allowBare); - if (doc && ((ownsEntry && doc.servers[slug] !== undefined) || bareCopy)) { + const bareCopy = isTeamaiBareCopy(doc, slug, owned); + const ownsEntry = ownsJsonMcpEntry(doc, slug, owned, allowBare); + if ((ownsEntry && doc.servers[slug] !== undefined) || bareCopy) { + const previousData = structuredClone(doc.data); if (ownsEntry) delete doc.servers[slug]; if (bareCopy) delete doc.data[slug]; await writeJsonDoc(targetFile, serverKey, doc); + restoreConfig = () => writeJsonAtomic(targetFile, previousData); + } + } + manifest[manifestKey] = owned.filter((r: ManagedMcpRecord) => r.name !== slug); + if (manifest[manifestKey].length === 0) delete manifest[manifestKey]; + try { + await writeJsonAtomic(manifestPath, manifest); + } catch (error) { + if (restoreConfig) { + try { + await restoreConfig(); + } catch (restoreError) { + throw new Error( + `uninstall_mcp: ownership write failed (${error instanceof Error ? error.message : String(error)}), and restoring ${targetFile} failed ` + + `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}). The config may not match ${manifestPath}. Repair the config and ownership record after fixing both write errors, then uninstall the server again.`, + { cause: error }, + ); + } } + throw error; } log.debug(`local-agent: uninstalled MCP server "${slug}" from ${tool} (scope=${scope})`); } diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 2280e0459..507a035f1 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -44,6 +44,7 @@ import { readJson, writeJsonAtomic, readFileSafe, + readFileIfExists, pathExists, expandHome, } from './utils/fs.js'; @@ -1248,11 +1249,31 @@ export async function reconcileMcpForConfig( // The (file, tool) pairs managed-mcp-files.json first recorded this run, before their write, until that // tool's records hold a resolved value there: another tool's write to the same file proves nothing of it. const recorded: McpTarget[] = []; + // One snapshot per file, before any tool writes it, until ownership is saved. + const restoreConfigs = new Map Promise>(); const protect = !options.removeAll && !options.dryRun; // Read before the reconcile records what it writes: a manifest it recreates says nothing of what came before. const before = protect && localConfig.projectRoot ? await readProjectMcpManifest(localConfig, localConfig.projectRoot) : undefined; try { - return await reconcileTargets(teamConfig, localConfig, options, exclusions, written, recorded); + return await reconcileTargets(teamConfig, localConfig, options, exclusions, written, recorded, restoreConfigs); + } catch (error) { + const failures: string[] = []; + for (const [file, restore] of restoreConfigs) { + try { + await restore(); + written.delete(file); + } catch (restoreError) { + failures.push(`${file}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`); + } + } + if (failures.length > 0) { + throw new Error( + `MCP sync failed (${error instanceof Error ? error.message : String(error)}), and restoring configs failed (${failures.join('; ')}). ` + + 'Their ownership records may not match. Repair the configs and ownership records before retrying the command.', + { cause: error }, + ); + } + throw error; } finally { // A record this run added for a tool that then wrote no value goes, as its exclude line does. The settle // below records the file again if it holds a resolved value all the same (an earlier pull wrote it). @@ -1572,6 +1593,7 @@ async function reconcileTargets( exclusions: Map, written: Set, recorded: McpTarget[], + restoreConfigs: Map Promise>, ): Promise { const changes: McpChange[] = []; let wrote = false; @@ -1667,8 +1689,8 @@ async function reconcileTargets( } const wroteTarget = target.format === 'codex' - ? await applyCodex(target, desired, keep, ownedNames, nextRecords, changes, options) - : await applyJson(target, desired, keep, owned, ownedNames, nextRecords, changes, options); + ? await applyCodex(target, desired, keep, ownedNames, nextRecords, changes, options, restoreConfigs) + : await applyJson(target, desired, keep, owned, ownedNames, nextRecords, changes, options, restoreConfigs); if (wroteTarget) written.add(target.file); wrote = wroteTarget || wrote; // Not read: its record stays as it was, or absent. An empty one would say teamai owns nothing there (#882). @@ -1684,8 +1706,6 @@ async function reconcileTargets( if (marked) record.unnoted = true; else delete record.unnoted; } - const at = recorded.indexOf(target); - if (at >= 0 && nextRecords.some((record) => record.resolved === true)) recorded.splice(at, 1); } // Rebuilt this run, or by one that could not note what else was in the file. const unnoted = manifest[manifestKey] === undefined || manifest[manifestKey].some((record) => record.unnoted); @@ -1719,6 +1739,12 @@ async function reconcileTargets( } await writeJsonAtomic(manifestPath, manifest); } + // Only committed ownership retains a file record added by this run. On failure, + // the outer cleanup removes it before inspecting the restored configs. + for (let index = recorded.length - 1; index >= 0; index--) { + const target = recorded[index]; + if (manifest[managedMcpManifestKey(target.tool, target.projectScope)]?.some((record) => record.resolved === true)) recorded.splice(index, 1); + } return { changes, wrote }; } @@ -1815,6 +1841,7 @@ async function applyJson( nextRecords: ManagedMcpRecord[], changes: McpChange[], options: McpReconcileOptions, + restoreConfigs: Map Promise>, ): Promise { const serverKey = MCP_SERVER_KEY[target.format as Exclude]; const allowBare = target.format === 'copilot' && target.projectScope; @@ -1823,6 +1850,8 @@ async function applyJson( log.warn(`Could not parse ${target.file} — skipping MCP injection for ${target.tool}`); return null; } + const existed = await pathExists(target.file); + const previousData = structuredClone(doc.data); const ownedHere = owned.filter((record) => ownsJsonMcpEntry(doc, record.name, [record], allowBare)); const ownedHash = new Map(ownedHere.map((r) => [r.name, r.hash])); @@ -1892,6 +1921,11 @@ async function applyJson( // empty `mcpServers` in a file the tool never reads under that name. // A file that holds a resolved value is the member's alone, an existing one tightened. await writeJsonDoc(target.file, serverKey, doc, holdsResolvedValue ? { mode: 0o600 } : undefined); + if (!restoreConfigs.has(target.file)) { + restoreConfigs.set(target.file, existed + ? () => writeJsonAtomic(target.file, previousData) + : () => fs.promises.rm(target.file, { force: true })); + } return true; } @@ -1903,8 +1937,10 @@ async function applyCodex( nextRecords: ManagedMcpRecord[], changes: McpChange[], options: McpReconcileOptions, + restoreConfigs: Map Promise>, ): Promise { - let source = (await readFileSafe(target.file)) ?? ''; + const previous = await readFileIfExists(target.file); + let source = previous ?? ''; const present = new Set(codexServerNames(source)); let dirty = false; let holdsResolvedValue = false; @@ -1951,6 +1987,11 @@ async function applyCodex( } await writeCodexAtomic(target.file, source); + if (!restoreConfigs.has(target.file)) { + restoreConfigs.set(target.file, previous === null + ? () => fs.promises.rm(target.file, { force: true }) + : () => writeCodexAtomic(target.file, previous)); + } return true; }